From 72dd01c55301fb7b87f0417d594d7738c91f3094 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 11:20:25 -0500 Subject: [PATCH] fix(desktop): no per-paragraph action bars on sealed interim messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #65919 the live view seals each chunk of mid-turn assistant commentary (message.interim) as its own finalized bubble. Every bubble with visible text renders the hover action footer, so a tool-heavy turn grew a copy/refresh bar under almost every paragraph — and the live render didn't match rehydration, which merges the turn into one bubble. Mark sealed interim bubbles with ChatMessage.interim, carry the flag into the runtime message metadata (custom.interim), and skip the AssistantFooter for them. The turn's final reply keeps the footer; a previewed final that settles onto an interim bubble clears the mark so the settled reply regains its actions. interim joins COMPARED_FIELDS / chatMessagesEquivalent so flipping it repaints. Also fix an id-collision flake this surfaced: stream/interim bubble ids were Date.now()-only, so an interim seal and the next segment's first delta in the same millisecond reused the id and the new segment appended into the sealed bubble. Ids now include a monotonic sequence. --- .../session/hooks/use-message-stream/index.ts | 39 ++++++------ .../interim-sealing.test.tsx | 30 ++++++++++ .../hooks/use-session-actions/utils.ts | 7 ++- .../assistant-ui/thread/assistant-message.tsx | 6 +- .../assistant-ui/thread/streaming.test.tsx | 59 +++++++++++++++++++ apps/desktop/src/lib/chat-messages.ts | 4 ++ apps/desktop/src/lib/chat-runtime.ts | 3 +- 7 files changed, 127 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index d72633ad7e58..3fe175b0fdaf 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -55,6 +55,13 @@ interface QueuedStreamDeltas { reasoning: string } +// Date.now() alone can collide when an interim seal and the next segment's +// first delta land in the same millisecond — the new segment would then find +// the sealed bubble by id and append into it instead of starting fresh. +let streamMessageSeq = 0 + +const nextStreamMessageId = (prefix: string) => `${prefix}-${Date.now()}-${++streamMessageSeq}` + export function useMessageStream({ activeGatewayProfile = 'default', activeSessionIdRef, @@ -91,7 +98,7 @@ export function useMessageStream({ return state } - const streamId = state.streamId ?? `assistant-stream-${Date.now()}` + const streamId = state.streamId ?? nextStreamMessageId('assistant-stream') const groupId = state.pendingBranchGroup ?? undefined const prev = state.messages let nextMessages: ChatMessage[] @@ -397,19 +404,21 @@ export function useMessageStream({ let nextMessages = state.messages if (streamId && nextMessages.some(m => m.id === streamId)) { - // Finalize the existing streaming bubble in place + // Seal the streaming bubble in place, marked interim so it renders + // without an action footer (see ChatMessage.interim). nextMessages = nextMessages.map(m => - m.id === streamId ? { ...m, parts: replaceTextPart(m.parts), pending: false } : m + m.id === streamId ? { ...m, parts: replaceTextPart(m.parts), pending: false, interim: true } : m ) } else { // No streaming bubble — create a standalone interim message nextMessages = [ ...nextMessages, { - id: `assistant-interim-${Date.now()}`, + id: nextStreamMessageId('assistant-interim'), role: 'assistant' as const, parts: [assistantTextPart(authoritativeText)], pending: false, + interim: true, branchGroupId: state.pendingBranchGroup ?? undefined } ] @@ -459,19 +468,15 @@ export function useMessageStream({ return mergeFinalAssistantText(parts, visibleFinalText) } - const completeMessage = (message: ChatMessage): ChatMessage => - completionError - ? { - ...message, - error: completionError, - parts: message.parts.filter(part => part.type !== 'text'), - pending: false - } - : { - ...message, - parts: replaceTextPart(message.parts), - pending: false - } + // Settling the final response onto a bubble makes it the turn's real + // reply — clear `interim` so it regains the action footer. + const completeMessage = (message: ChatMessage): ChatMessage => { + const settled = { ...message, pending: false, interim: false } + + return completionError + ? { ...settled, error: completionError, parts: message.parts.filter(part => part.type !== 'text') } + : { ...settled, parts: replaceTextPart(message.parts) } + } const newAssistantFromCompletion = (): ChatMessage => ({ id: `assistant-${Date.now()}`, diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx index 2ee5bb720418..9a10e6c9b183 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx @@ -111,6 +111,36 @@ describe('useMessageStream interim text sealing', () => { expect(texts).toContain('All checks passed.') }) + it('marks sealed interim bubbles interim and leaves the final reply unmarked', async () => { + await mountStream() + await start() + + await delta('Let me check the files.') + await interim('Let me check the files.') + await delta('Now the second pass.') + await interim('Now the second pass.') + await complete('All done.') + + const assistants = getState().messages.filter(m => m.role === 'assistant' && !m.hidden) + const byText = (text: string) => assistants.find(m => chatMessageText(m) === text) + + expect(byText('Let me check the files.')?.interim).toBe(true) + expect(byText('Now the second pass.')?.interim).toBe(true) + expect(byText('All done.')?.interim).toBeFalsy() + }) + + it('clears the interim mark when a previewed final settles onto the interim bubble', async () => { + await mountStream() + await start() + + await interim('same reply') + await completePreviewed('same reply') + + const assistants = getState().messages.filter(m => m.role === 'assistant' && !m.hidden) + expect(assistants).toHaveLength(1) + expect(assistants[0].interim).toBeFalsy() + }) + it('dedupes interim text when the final response includes it', async () => { await mountStream() await start() diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 927b72d6d414..feb22b9e658a 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -73,7 +73,7 @@ const _chatMessageFieldsExhaustive: { [K in Exclude]: never } = {} -const COMPARED_FIELDS = ['id', 'role', 'pending', 'error', 'hidden', 'branchGroupId'] as const +const COMPARED_FIELDS = ['id', 'role', 'pending', 'error', 'hidden', 'branchGroupId', 'interim'] as const const IGNORED_FIELDS = ['timestamp', 'attachmentRefs', 'parts'] as const // Compile-time check: every ChatMessagePart discriminant must be handled by @@ -154,7 +154,10 @@ export function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean a.pending !== b.pending || a.error !== b.error || a.hidden !== b.hidden || - a.branchGroupId !== b.branchGroupId + a.branchGroupId !== b.branchGroupId || + // Interim gates the action footer, so flipping it must repaint (e.g. a + // previewed final settling onto a sealed interim bubble restores the bar). + (a.interim ?? false) !== (b.interim ?? false) ) { return false } diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx index 5c73a80a5f72..e9ac8ad28a87 100644 --- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx @@ -65,6 +65,10 @@ export const AssistantMessage: FC<{ const isRunning = messageStatus === 'running' const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0) const hasVisibleText = useAuiState(s => contentHasVisibleText(s.message.content)) + // Sealed mid-turn commentary keeps its text but not the footer, so a + // tool-heavy turn doesn't grow a copy/refresh bar per paragraph (see + // ChatMessage.interim). + const isInterim = useAuiState(s => s.message.metadata?.custom?.interim === true) // Preview targets only materialize once the turn completes — while running // the selector returns '' (stable), so per-token flushes skip the regex @@ -130,7 +134,7 @@ export const AssistantMessage: FC<{ - {hasVisibleText && ( + {hasVisibleText && !isInterim && ( )} diff --git a/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx b/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx index c78aeedc6e8a..8c31a9185f1d 100644 --- a/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx @@ -328,6 +328,37 @@ function MessageHarness({ message }: { message: ThreadMessage }) { ) } +function TranscriptHarness({ messages }: { messages: ThreadMessage[] }) { + const runtime = useExternalStoreRuntime({ + messages, + isRunning: false, + onNew: async () => {} + }) + + return ( + + + + ) +} + +function assistantInterimMessage(text: string, id = 'assistant-interim-1'): ThreadMessage { + return { + id, + role: 'assistant', + content: [{ type: 'text', text }], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: { interim: true } + } + } as ThreadMessage +} + function RunningMessageHarness({ message }: { message: ThreadMessage }) { const runtime = useExternalStoreRuntime({ messages: [message], @@ -455,6 +486,34 @@ describe('assistant-ui streaming renderer', () => { expect(container.querySelector('[data-slot="aui_composer-clearance"]')).toBeNull() }) + it('suppresses the action footer on sealed interim messages, keeping it on the final reply', () => { + const { container } = render( + + ) + + // Interim commentary stays visible… + expect(container.textContent).toContain('Let me check the files.') + expect(container.textContent).toContain('Now applying the patch.') + expect(container.textContent).toContain('All done — patch applied.') + + // …but only the turn's final reply carries the copy/refresh action bar. + const actionBars = container.querySelectorAll('[data-slot="aui_msg-actions"]') + expect(actionBars).toHaveLength(1) + + const finalRoot = [...container.querySelectorAll('[data-slot="aui_assistant-message-root"]')].find(root => + root.textContent?.includes('All done — patch applied.') + ) + + expect(finalRoot?.querySelector('[data-slot="aui_msg-actions"]')).toBeTruthy() + }) + it('renders assistant provider errors inline', () => { render() diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 4bc50d32569a..0a6d56d5a6b3 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -17,6 +17,10 @@ export type ChatMessage = { error?: string branchGroupId?: string hidden?: boolean + /** Sealed mid-turn commentary (`message.interim`) — rendered without the + * action footer so only the turn's final reply carries copy/refresh, and + * the live view matches rehydration (which merges the turn into one bubble). */ + interim?: boolean /** Composer attachment ref strings (`@file:...`, `@image:...`) sent with this user message. */ attachmentRefs?: string[] } diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 775961d9546b..d2cacf876006 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -381,7 +381,8 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage { unstable_annotations: [], unstable_data: [], steps: [], - custom: {} + // Carries ChatMessage.interim to AssistantMessage's footer gate. + custom: message.interim ? { interim: true } : {} } } as ThreadMessage }