From 0f9092992476930b9cad80591ac38e0b8a9751fd Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Fri, 27 Mar 2026 15:46:08 +0100 Subject: [PATCH] fix(ui): reset transcript when replay starts --- ui/src/lib/chat-transcript-session.test.ts | 15 ++++++++ ui/src/lib/chat-transcript-session.ts | 26 +++++++++++-- ui/src/lib/use-chat-connection.ts | 5 ++- ui/src/pages/chat.test.tsx | 45 ++++++++++++++++++++++ 4 files changed, 87 insertions(+), 4 deletions(-) diff --git a/ui/src/lib/chat-transcript-session.test.ts b/ui/src/lib/chat-transcript-session.test.ts index e4b737a..a4d59c9 100644 --- a/ui/src/lib/chat-transcript-session.test.ts +++ b/ui/src/lib/chat-transcript-session.test.ts @@ -33,6 +33,21 @@ describe('chat-transcript-session', () => { expect(session.transcript.messages[0].streaming).toBe(false); }); + it('replaces stale local transcript state when canonical replay starts', () => { + const session = createChatTranscriptSession(''); + applyChatTranscriptUpdate(session, { + sessionUpdate: 'agent_message_chunk', + content: 'Based on the German residence law documents...', + }); + finalizePromptStreaming(session); + + noteReplayState(session, true); + + expect(session.transcript.messages).toEqual([]); + expect(session.cacheHydrated).toBe(false); + expect(session.replaySawTranscriptUpdate).toBe(false); + }); + it('drops stale cached state when replay had no transcript-bearing updates', () => { const session = createChatTranscriptSession(''); session.cacheHydrated = true; diff --git a/ui/src/lib/chat-transcript-session.ts b/ui/src/lib/chat-transcript-session.ts index 815319a..5e391d4 100644 --- a/ui/src/lib/chat-transcript-session.ts +++ b/ui/src/lib/chat-transcript-session.ts @@ -44,10 +44,30 @@ export function replaceChatTranscriptSession(conversationId: string): ChatTransc /** * Resets replay bookkeeping when ACP begins replaying historical updates. */ -export function noteReplayState(session: ChatTranscriptSession, replaying: boolean): void { - if (replaying) { - session.replaySawTranscriptUpdate = false; +export function noteReplayState(session: ChatTranscriptSession, replaying: boolean): ACPTranscript | null { + if (!replaying) { + return null; } + + session.replaySawTranscriptUpdate = false; + + const shouldReplaceTranscript = + session.cacheHydrated || + session.transcript.messages.length > 0 || + session.transcript.thinkingChunks.length > 0 || + session.transcript.availableCommands.length > 0 || + session.transcript.currentMode !== '' || + session.transcript.usage !== null; + + if (!shouldReplaceTranscript) { + return null; + } + + // Backend replay is the source of truth, so drop any cached/live transcript + // state before historical updates start arriving. + session.transcript = createTranscript(); + session.cacheHydrated = false; + return session.transcript; } /** diff --git a/ui/src/lib/use-chat-connection.ts b/ui/src/lib/use-chat-connection.ts index 07814dc..4166f57 100644 --- a/ui/src/lib/use-chat-connection.ts +++ b/ui/src/lib/use-chat-connection.ts @@ -249,7 +249,10 @@ export function useChatConnection({ }, onReplayStateChange: (replaying) => { if (cancelled) return; - noteReplayState(transcriptSessionRef.current, replaying); + const nextTranscript = noteReplayState(transcriptSessionRef.current, replaying); + if (replaying && nextTranscript) { + syncTranscript(nextTranscript); + } }, onUpdate: (update, updateOptions) => { if (cancelled) return; diff --git a/ui/src/pages/chat.test.tsx b/ui/src/pages/chat.test.tsx index ee89f9f..4ce6092 100644 --- a/ui/src/pages/chat.test.tsx +++ b/ui/src/pages/chat.test.tsx @@ -759,6 +759,51 @@ describe('ChatPage draft persistence', () => { }); }); + it('replaces stale live assistant turns with canonical replay order on reconnect', async () => { + await renderChat('/c/covo/conv-1'); + + act(() => { + emitUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Based on the German residence law documents...' }, + }); + }); + + await waitFor(() => { + const messages = screen.getAllByTestId('chat-message').map((element) => element.textContent); + expect(messages).toEqual(['assistant:Based on the German residence law documents...']); + }); + + act(() => { + emitReplayState(true); + emitUpdate({ + sessionUpdate: 'agent_message_chunk', + historyMessageId: 'assistant-1', + content: { type: 'text', text: 'I need to clarify: tc is the TextCortex CLI.' }, + }, { historical: true }); + emitUpdate({ + sessionUpdate: 'agent_message_chunk', + historyMessageId: 'assistant-2', + content: { type: 'text', text: "You're right — `tc kb search` lets you search your own knowledge bases." }, + }, { historical: true }); + emitUpdate({ + sessionUpdate: 'agent_message_chunk', + historyMessageId: 'assistant-3', + content: { type: 'text', text: 'Based on the German residence law documents...' }, + }, { historical: true }); + emitReplayState(false); + }); + + await waitFor(() => { + const messages = screen.getAllByTestId('chat-message').map((element) => element.textContent); + expect(messages).toEqual([ + 'assistant:I need to clarify: tc is the TextCortex CLI.', + "assistant:You're right — `tc kb search` lets you search your own knowledge bases.", + 'assistant:Based on the German residence law documents...', + ]); + }); + }); + it('restores the original conversation draft when send fails after switching chats', async () => { const user = userEvent.setup(); const deferred = createDeferred();