feat(core): support lazy load for ai session history (#13221)

Close [AI-331](https://linear.app/affine-design/issue/AI-331)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## 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.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Wu Yue
2025-07-15 19:43:36 +08:00
committed by GitHub
parent c797cac87d
commit a4b535a42a
5 changed files with 93 additions and 42 deletions
@@ -407,7 +407,8 @@ declare global {
) => Promise<CopilotChatHistoryFragment[] | undefined>; ) => Promise<CopilotChatHistoryFragment[] | undefined>;
getRecentSessions: ( getRecentSessions: (
workspaceId: string, workspaceId: string,
limit?: number limit?: number,
offset?: number
) => Promise<AIRecentSession[] | undefined>; ) => Promise<AIRecentSession[] | undefined>;
updateSession: (options: UpdateChatSessionInput) => Promise<string>; updateSession: (options: UpdateChatSessionInput) => Promise<string>;
} }
@@ -106,7 +106,7 @@ export class AIChatPanelTitle extends SignalWatcher(
accessor notificationService!: NotificationService; accessor notificationService!: NotificationService;
@property({ attribute: false }) @property({ attribute: false })
accessor session!: CopilotChatHistoryFragment; accessor session!: CopilotChatHistoryFragment | null | undefined;
@property({ attribute: false }) @property({ attribute: false })
accessor status!: ChatStatus; accessor status!: ChatStatus;
@@ -3,8 +3,8 @@ import { WithDisposable } from '@blocksuite/affine/global/lit';
import { scrollbarStyle } from '@blocksuite/affine/shared/styles'; import { scrollbarStyle } from '@blocksuite/affine/shared/styles';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme'; import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { ShadowlessElement } from '@blocksuite/affine/std'; import { ShadowlessElement } from '@blocksuite/affine/std';
import { css, html, nothing } from 'lit'; import { css, html, nothing, type PropertyValues } from 'lit';
import { property, state } from 'lit/decorators.js'; import { property, query, state } from 'lit/decorators.js';
import { AIProvider } from '../../provider'; import { AIProvider } from '../../provider';
import type { DocDisplayConfig } from '../ai-chat-chips'; import type { DocDisplayConfig } from '../ai-chat-chips';
@@ -133,11 +133,21 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
@property({ attribute: false }) @property({ attribute: false })
accessor onDocClick!: (docId: string, sessionId: string) => void; accessor onDocClick!: (docId: string, sessionId: string) => void;
@state() @query('.ai-session-history')
private accessor sessions: BlockSuitePresets.AIRecentSession[] = []; accessor scrollContainer!: HTMLElement;
@state() @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( private groupSessionsByTime(
sessions: BlockSuitePresets.AIRecentSession[] sessions: BlockSuitePresets.AIRecentSession[]
@@ -188,23 +198,46 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
} }
private async getRecentSessions() { private async getRecentSessions() {
this.loading = true; this.loadingMore = true;
const limit = 50;
const sessions = await AIProvider.session?.getRecentSessions( const moreSessions =
this.workspaceId, (await AIProvider.session?.getRecentSessions(
limit this.workspaceId,
); this.pageSize,
if (sessions) { this.currentOffset
this.sessions = sessions; )) || [];
} this.sessions = [...(this.sessions || []), ...moreSessions];
this.loading = false;
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() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.getRecentSessions().catch(console.error); 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( private renderSessionGroup(
title: string, title: string,
sessions: BlockSuitePresets.AIRecentSession[] sessions: BlockSuitePresets.AIRecentSession[]
@@ -256,35 +289,43 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
</div>`; </div>`;
} }
override render() { private renderLoading() {
if (this.loading) { return html`
return html` <div class="loading-container">
<div class="ai-session-history"> <div class="loading-title">Loading history...</div>
<div class="loading-container"> </div>
<div class="loading-title">Loading history...</div> `;
</div> }
</div>
`; private renderEmpty() {
return html`
<div class="empty-container">
<div class="empty-title">Empty history</div>
</div>
`;
}
private renderHistory() {
if (!this.sessions) {
return this.renderLoading();
} }
if (this.sessions.length === 0) { if (this.sessions.length === 0) {
return html` return this.renderEmpty();
<div class="ai-session-history">
<div class="empty-container">
<div class="empty-title">Empty history</div>
</div>
</div>
`;
} }
const groupedSessions = this.groupSessionsByTime(this.sessions); const groupedSessions = this.groupSessionsByTime(this.sessions);
return html` return html`
<div class="ai-session-history"> ${this.renderSessionGroup('Today', groupedSessions.today)}
${this.renderSessionGroup('Today', groupedSessions.today)} ${this.renderSessionGroup('Last 7 days', groupedSessions.last7Days)}
${this.renderSessionGroup('Last 7 days', groupedSessions.last7Days)} ${this.renderSessionGroup('Last 30 days', groupedSessions.last30Days)}
${this.renderSessionGroup('Last 30 days', groupedSessions.last30Days)} ${this.renderSessionGroup('Older', groupedSessions.older)}
${this.renderSessionGroup('Older', groupedSessions.older)} `;
</div> }
override render() {
return html`
<div class="ai-session-history">${this.renderHistory()}</div>
`; `;
} }
} }
@@ -186,13 +186,18 @@ export class CopilotClient {
} }
} }
async getRecentSessions(workspaceId: string, limit?: number) { async getRecentSessions(
workspaceId: string,
limit?: number,
offset?: number
) {
try { try {
const res = await this.gql({ const res = await this.gql({
query: getCopilotRecentSessionsQuery, query: getCopilotRecentSessionsQuery,
variables: { variables: {
workspaceId, workspaceId,
limit, limit,
offset,
}, },
}); });
return res.currentUser?.copilot?.chats.edges.map(e => e.node); return res.currentUser?.copilot?.chats.edges.map(e => e.node);
@@ -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); return client.getSessions(workspaceId, {}, docId, options);
}, },
getRecentSessions: async (workspaceId: string, limit?: number) => { getRecentSessions: async (
return client.getRecentSessions(workspaceId, limit); workspaceId: string,
limit?: number,
offset?: number
) => {
return client.getRecentSessions(workspaceId, limit, offset);
}, },
updateSession: async (options: UpdateChatSessionInput) => { updateSession: async (options: UpdateChatSessionInput) => {
return client.updateSession(options); return client.updateSession(options);