From a4b535a42af2f5ed7b2e101d4cfb0c2bd4bc2afc Mon Sep 17 00:00:00 2001 From: Wu Yue Date: Tue, 15 Jul 2025 19:43:36 +0800 Subject: [PATCH] feat(core): support lazy load for ai session history (#13221) Close [AI-331](https://linear.app/affine-design/issue/AI-331) ## Summary by CodeRabbit * **New Features** * Added infinite scroll and incremental loading for AI session history, allowing users to load more sessions as they scroll. * **Refactor** * Improved session history component with better state management and modular rendering for loading, empty, and history states. * **Bug Fixes** * Enhanced handling of absent or uninitialized chat sessions, reducing potential errors when session data is missing. --- .../core/src/blocksuite/ai/actions/types.ts | 3 +- .../src/blocksuite/ai/chat-panel/ai-title.ts | 2 +- .../ai-chat-toolbar/ai-session-history.ts | 115 ++++++++++++------ .../blocksuite/ai/provider/copilot-client.ts | 7 +- .../blocksuite/ai/provider/setup-provider.tsx | 8 +- 5 files changed, 93 insertions(+), 42 deletions(-) diff --git a/packages/frontend/core/src/blocksuite/ai/actions/types.ts b/packages/frontend/core/src/blocksuite/ai/actions/types.ts index 92e7c5810..9571060d2 100644 --- a/packages/frontend/core/src/blocksuite/ai/actions/types.ts +++ b/packages/frontend/core/src/blocksuite/ai/actions/types.ts @@ -407,7 +407,8 @@ declare global { ) => Promise; getRecentSessions: ( workspaceId: string, - limit?: number + limit?: number, + offset?: number ) => Promise; updateSession: (options: UpdateChatSessionInput) => Promise; } diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/ai-title.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/ai-title.ts index 9e8462b77..e01dc8031 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/ai-title.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/ai-title.ts @@ -106,7 +106,7 @@ export class AIChatPanelTitle extends SignalWatcher( accessor notificationService!: NotificationService; @property({ attribute: false }) - accessor session!: CopilotChatHistoryFragment; + accessor session!: CopilotChatHistoryFragment | null | undefined; @property({ attribute: false }) accessor status!: ChatStatus; diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-session-history.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-session-history.ts index a7f56b392..85cdb184c 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-session-history.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-session-history.ts @@ -3,8 +3,8 @@ import { WithDisposable } from '@blocksuite/affine/global/lit'; import { scrollbarStyle } from '@blocksuite/affine/shared/styles'; import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme'; import { ShadowlessElement } from '@blocksuite/affine/std'; -import { css, html, nothing } from 'lit'; -import { property, state } from 'lit/decorators.js'; +import { css, html, nothing, type PropertyValues } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; import { AIProvider } from '../../provider'; import type { DocDisplayConfig } from '../ai-chat-chips'; @@ -133,11 +133,21 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) { @property({ attribute: false }) accessor onDocClick!: (docId: string, sessionId: string) => void; - @state() - private accessor sessions: BlockSuitePresets.AIRecentSession[] = []; + @query('.ai-session-history') + accessor scrollContainer!: HTMLElement; @state() - private accessor loading = true; + private accessor sessions: BlockSuitePresets.AIRecentSession[] | undefined; + + @state() + private accessor loadingMore = false; + + @state() + private accessor hasMore = true; + + private accessor currentOffset = 0; + + private readonly pageSize = 10; private groupSessionsByTime( sessions: BlockSuitePresets.AIRecentSession[] @@ -188,23 +198,46 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) { } private async getRecentSessions() { - this.loading = true; - const limit = 50; - const sessions = await AIProvider.session?.getRecentSessions( - this.workspaceId, - limit - ); - if (sessions) { - this.sessions = sessions; - } - this.loading = false; + this.loadingMore = true; + + const moreSessions = + (await AIProvider.session?.getRecentSessions( + this.workspaceId, + this.pageSize, + this.currentOffset + )) || []; + this.sessions = [...(this.sessions || []), ...moreSessions]; + + this.currentOffset += moreSessions.length; + this.hasMore = moreSessions.length === this.pageSize; + this.loadingMore = false; } + private readonly onScroll = () => { + if (!this.hasMore || this.loadingMore) { + return; + } + // load more when within 50px of bottom + const { scrollTop, scrollHeight, clientHeight } = this.scrollContainer; + const threshold = 50; + if (scrollTop + clientHeight >= scrollHeight - threshold) { + this.getRecentSessions().catch(console.error); + } + }; + override connectedCallback() { super.connectedCallback(); this.getRecentSessions().catch(console.error); } + override firstUpdated(changedProperties: PropertyValues) { + super.firstUpdated(changedProperties); + this.disposables.add(() => { + this.scrollContainer.removeEventListener('scroll', this.onScroll); + }); + this.scrollContainer.addEventListener('scroll', this.onScroll); + } + private renderSessionGroup( title: string, sessions: BlockSuitePresets.AIRecentSession[] @@ -256,35 +289,43 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) { `; } - override render() { - if (this.loading) { - return html` -
-
-
Loading history...
-
-
- `; + private renderLoading() { + return html` +
+
Loading history...
+
+ `; + } + + private renderEmpty() { + return html` +
+
Empty history
+
+ `; + } + + private renderHistory() { + if (!this.sessions) { + return this.renderLoading(); } if (this.sessions.length === 0) { - return html` -
-
-
Empty history
-
-
- `; + return this.renderEmpty(); } const groupedSessions = this.groupSessionsByTime(this.sessions); return html` -
- ${this.renderSessionGroup('Today', groupedSessions.today)} - ${this.renderSessionGroup('Last 7 days', groupedSessions.last7Days)} - ${this.renderSessionGroup('Last 30 days', groupedSessions.last30Days)} - ${this.renderSessionGroup('Older', groupedSessions.older)} -
+ ${this.renderSessionGroup('Today', groupedSessions.today)} + ${this.renderSessionGroup('Last 7 days', groupedSessions.last7Days)} + ${this.renderSessionGroup('Last 30 days', groupedSessions.last30Days)} + ${this.renderSessionGroup('Older', groupedSessions.older)} + `; + } + + override render() { + return html` +
${this.renderHistory()}
`; } } diff --git a/packages/frontend/core/src/blocksuite/ai/provider/copilot-client.ts b/packages/frontend/core/src/blocksuite/ai/provider/copilot-client.ts index 474b0d9c2..d46468788 100644 --- a/packages/frontend/core/src/blocksuite/ai/provider/copilot-client.ts +++ b/packages/frontend/core/src/blocksuite/ai/provider/copilot-client.ts @@ -186,13 +186,18 @@ export class CopilotClient { } } - async getRecentSessions(workspaceId: string, limit?: number) { + async getRecentSessions( + workspaceId: string, + limit?: number, + offset?: number + ) { try { const res = await this.gql({ query: getCopilotRecentSessionsQuery, variables: { workspaceId, limit, + offset, }, }); return res.currentUser?.copilot?.chats.edges.map(e => e.node); diff --git a/packages/frontend/core/src/blocksuite/ai/provider/setup-provider.tsx b/packages/frontend/core/src/blocksuite/ai/provider/setup-provider.tsx index b917a1b01..67ed87c4e 100644 --- a/packages/frontend/core/src/blocksuite/ai/provider/setup-provider.tsx +++ b/packages/frontend/core/src/blocksuite/ai/provider/setup-provider.tsx @@ -589,8 +589,12 @@ Could you make a new website based on these notes and send back just the html fi ) => { return client.getSessions(workspaceId, {}, docId, options); }, - getRecentSessions: async (workspaceId: string, limit?: number) => { - return client.getRecentSessions(workspaceId, limit); + getRecentSessions: async ( + workspaceId: string, + limit?: number, + offset?: number + ) => { + return client.getRecentSessions(workspaceId, limit, offset); }, updateSession: async (options: UpdateChatSessionInput) => { return client.updateSession(options);