diff --git a/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts b/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts index 30683c3a6..431649bd4 100644 --- a/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts +++ b/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts @@ -539,10 +539,19 @@ describe('acpChatSessionStore', () => { const loadingSnapshot = acpChatSessionActions.startSessionLoad(currentSessionId); expect(loadingSnapshot.messages).toEqual([]); + // While a load replay is in flight, per-notification snapshots stay frozen + // at the load-start state (avoids O(n^2) cloning on large sessions); the + // replayed conversation materializes in the finishSessionLoad snapshot. const replayedSnapshot = acpChatSessionActions.applyAcpSessionNotification(replayedChunk); + expect(replayedSnapshot.messages).toEqual([]); - expect(replayedSnapshot.messages).toHaveLength(1); - expect(replayedSnapshot.messages[0].content).toEqual([{ type: 'text', text: 'Hello' }]); + const finishedSnapshot = acpChatSessionActions.finishSessionLoad( + currentSessionId, + session(currentSessionId) + ); + + expect(finishedSnapshot.messages).toHaveLength(1); + expect(finishedSnapshot.messages[0].content).toEqual([{ type: 'text', text: 'Hello' }]); }); it('applies permission requests as waiting action-required messages', () => { diff --git a/ui/desktop/src/acp/adapter/shared.ts b/ui/desktop/src/acp/adapter/shared.ts index 389aff824..aa5f033f8 100644 --- a/ui/desktop/src/acp/adapter/shared.ts +++ b/ui/desktop/src/acp/adapter/shared.ts @@ -40,7 +40,11 @@ export const DEFAULT_VISIBLE_MESSAGE_METADATA: Message['metadata'] = { }; export function messagesChange(state: AdapterState): AcpChatStateChange[] { - return [{ type: 'messages', messages: state.messages.map(cloneMessage) }]; + // Pass the live array by reference: the store is the only consumer and it + // clones on write (applyChatStateChanges). Cloning here as well made every + // streamed chunk O(messages) twice, which turns session-load replay into + // O(n^2) on large sessions. + return [{ type: 'messages', messages: state.messages }]; } export function cloneMessage(message: Message): Message { diff --git a/ui/desktop/src/acp/chatSessionStore.ts b/ui/desktop/src/acp/chatSessionStore.ts index 5a4cea6b0..19fa8dcfb 100644 --- a/ui/desktop/src/acp/chatSessionStore.ts +++ b/ui/desktop/src/acp/chatSessionStore.ts @@ -39,6 +39,10 @@ interface StoreEntry extends AcpChatSessionSnapshot { pendingUserInputRequestIds: Set; pendingLocalSteerMessageIds: Set; preConfirmedSteerMessageIds: Set; + // Cached result of the last notify(); reused while a session-load replay is + // in flight so per-notification reads don't deep-clone the growing message + // array (see applyAcpSessionNotification / getSnapshot). + lastSnapshot?: AcpChatSessionSnapshot; } const initialTokenState: TokenState = { @@ -115,7 +119,16 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal { const getSnapshot: AcpChatSessionStore['getSnapshot'] = (sessionId) => { const entry = sessionsById.get(sessionId); - return entry ? snapshotFromEntry(entry) : undefined; + if (!entry) { + return undefined; + } + // While a session-load replay is streaming in, serve the snapshot cached + // by the last notify() instead of deep-cloning the growing message array + // on every read (getSnapshot is called per replay notification). + if (entry.chatState === ChatState.LoadingConversation && entry.lastSnapshot) { + return entry.lastSnapshot; + } + return snapshotFromEntry(entry); }; const subscribe: AcpChatSessionStoreInternal['subscribe'] = (sessionId, listener) => { @@ -175,6 +188,7 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal { const notify = (sessionId: string, entry: StoreEntry): AcpChatSessionSnapshot => { const snapshot = snapshotFromEntry(entry); + entry.lastSnapshot = snapshot; const listeners = listenersBySessionId.get(sessionId); if (listeners) { for (const listener of listeners) { @@ -204,6 +218,10 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal { entry.session = session; entry.sessionLoadError = undefined; entry.progressMessage = undefined; + // Materialize the replayed conversation in one pass (the per-notification + // fast path above skips message copies while loading). + entry.messages = entry.adapter.getMessages(); + retainPendingLocalSteerMessageIds(entry); entry.chatState = entry.activePromptAttemptId ? ChatState.Streaming : ChatState.Idle; return notify(sessionId, entry); }; @@ -435,6 +453,17 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal { entry.progressMessage = undefined; } const changes = entry.adapter.apply(notification); + // Session-load replay fast path: messages accumulate inside the adapter + // and are materialized once in finishSessionLoad. Copying them into the + // entry and re-notifying per replayed message is O(n^2) over the whole + // conversation and freezes the renderer on large sessions. + if (entry.chatState === ChatState.LoadingConversation && entry.lastSnapshot) { + applyChatStateChanges( + entry, + changes.filter((change) => change.type !== 'messages') + ); + return entry.lastSnapshot; + } applyChatStateChanges(entry, changes); return notify(notification.sessionId, entry); }; @@ -443,6 +472,14 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal { (notification) => { const entry = getOrCreateEntry(notification.sessionId); const changes = entry.adapter.applyGoose(notification); + // Same session-load replay fast path as applyAcpSessionNotification. + if (entry.chatState === ChatState.LoadingConversation && entry.lastSnapshot) { + applyChatStateChanges( + entry, + changes.filter((change) => change.type !== 'messages') + ); + return entry.lastSnapshot; + } applyChatStateChanges(entry, changes); return notify(notification.sessionId, entry); };