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 }