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 0aaf5430f0..47119752c7 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 @@ -157,6 +157,7 @@ const _chatMessageFieldsExhaustive: { const COMPARED_FIELDS = [ 'asyncResult', + 'asyncResultKind', 'id', 'role', 'pending', 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 0fc5bedc4e..22e02c5ce4 100644 --- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx @@ -8,7 +8,7 @@ import { useThreadRuntime } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { type FC, type ReactNode, useCallback, useMemo, useState } from 'react' +import { type FC, type ReactNode, useCallback, useContext, useMemo, useState } from 'react' import { useInRouterContext, useNavigate } from 'react-router' import { useSessionView } from '@/app/chat/session-view' @@ -21,6 +21,7 @@ import { } from '@/components/assistant-ui/thread/content' import { MESSAGE_PARTS_COMPONENTS } from '@/components/assistant-ui/thread/message-parts' import { ReactionPicker } from '@/components/assistant-ui/thread/message-reactions' +import { ResponseMessageIds } from '@/components/assistant-ui/thread/response-group' import { ResponseLoadingIndicator, TurnActivityIndicator } from '@/components/assistant-ui/thread/status' import { MessageTimelineTimestamp } from '@/components/assistant-ui/thread/timeline-timestamp' import { useMessageReactions, useTapbackDoubleClick } from '@/components/assistant-ui/thread/use-message-reactions' @@ -185,6 +186,9 @@ const AssistantMessageBody: FC { const messageId = useAuiState(s => s.message.id) const messageRuntime = useMessageRuntime() + const threadRuntime = useThreadRuntime() + const responseIds = useContext(ResponseMessageIds) + const responseTail = responseIds.length === 0 || responseIds.at(-1) === messageId const { t } = useI18n() // PERF: this component must NOT subscribe to the streaming text, and no @@ -203,7 +207,16 @@ const AssistantMessageBody: FC s.message.metadata?.custom?.durationS as number | undefined) - const getMessageText = useCallback(() => messageContentText(messageRuntime.getState().content), [messageRuntime]) + const getMessageText = useCallback( + () => + responseIds.length + ? responseIds + .map(id => messageContentText(threadRuntime.getMessageById(id).getState().content)) + .filter(Boolean) + .join('\n\n') + : messageContentText(messageRuntime.getState().content), + [messageRuntime, responseIds, threadRuntime] + ) // useEnterAnimation consults `enabled` ONLY when its callback ref fires, // i.e. at mount: the hook parks the value in a ref and returns a @@ -269,7 +282,7 @@ const AssistantMessageBody: FC - {hasVisibleText && !isInterim && ( + {hasVisibleText && !isInterim && responseTail && ( - {group.indices.map(index => ( - - ))} + ) : ( @@ -436,7 +435,7 @@ const ThreadMessageListInner: FC = ({ // every tick (measured: 540 wasted Block renders per explain() sample with // two threads streaming). const structuralSignature = useAuiState(s => - s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n') + s.thread.messages.map((message, index) => `${index}:${message.id}:${responseMessageRole(message)}`).join('\n') ) const weightSignature = useAuiState(s => diff --git a/apps/desktop/src/components/assistant-ui/thread/process-notification.test.tsx b/apps/desktop/src/components/assistant-ui/thread/process-notification.test.tsx new file mode 100644 index 0000000000..d15a6c94e8 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/process-notification.test.tsx @@ -0,0 +1,50 @@ +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' + +import { toChatMessages } from '@/lib/chat-messages' +import { toRuntimeMessage } from '@/lib/chat-runtime' +import type { SessionMessage } from '@/types/hermes' + +import { stubThreadEnvironment, ThreadRuntime } from '../test-utils' + +import { Thread } from '.' + +stubThreadEnvironment() +afterEach(cleanup) + +it('renders legacy and current process completions as tool disclosures without interpreting their output', () => { + const headline = 'Background process proc_example completed normally (exit code 0).' + const output = 'Command: node verify.mjs\nOutput:\n# literal output\n[not a link](https://example.com)' + const content = `[IMPORTANT: ${headline}\n${output}]` + + for (const display of [ + {}, + { display_kind: 'process_complete', display_metadata: { display_text: 'Background Process Finished: node verify.mjs' } } + ]) { + const messages = toChatMessages([ + { role: 'user', content, timestamp: 1, ...display } as SessionMessage + ]).map(toRuntimeMessage) + + const { container, getByRole, unmount } = render( + + + + ) + + const title = display.display_metadata?.display_text ?? headline + const toggle = getByRole('button', { name: title }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(toggle.closest('[data-conversation-scaffold]')).toBeTruthy() + expect(container.textContent).not.toContain('# literal output') + expect(container.querySelector('details')).toBeNull() + + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(container.textContent).toContain(output) + expect(container.querySelector('a[href="https://example.com"]')).toBeNull() + + fireEvent.click(toggle) + expect(container.textContent).not.toContain('# literal output') + unmount() + } +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/response-group.test.tsx b/apps/desktop/src/components/assistant-ui/thread/response-group.test.tsx new file mode 100644 index 0000000000..81ddcf8ca9 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/response-group.test.tsx @@ -0,0 +1,88 @@ +import type { ThreadMessage } from '@assistant-ui/react' +import { cleanup, fireEvent, render, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +import { toChatMessages } from '@/lib/chat-messages' +import { toRuntimeMessage } from '@/lib/chat-runtime' +import type { SessionMessage } from '@/types/hermes' + +import { stubThreadEnvironment, ThreadRuntime } from '../test-utils' + +import { Thread } from '.' + +beforeEach(stubThreadEnvironment) +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +it('keeps background continuations in one response with one action bar and the original message identities', async () => { + const content = '[IMPORTANT: Background process proc_example completed normally (exit code 0).\nOutput:\nVerified.]' + const clipboard = { writeText: vi.fn().mockResolvedValue(undefined) } + vi.stubGlobal('navigator', { ...navigator, clipboard }) + const branch = vi.fn() + + for (const display of [ + {}, + { display_kind: 'process_complete', display_metadata: { display_text: 'Background Process Finished: verify' } }, + { display_kind: 'async_delegation_complete', display_metadata: { display_text: 'Background agent finished' } } + ]) { + const stored = [ + { role: 'user', content: 'Verify it.', timestamp: 1 }, + { role: 'assistant', content: 'Checking the deployment.', timestamp: 2 }, + { role: 'user', content, timestamp: 3, ...display }, + { role: 'assistant', content: 'The deployment is verified.', timestamp: 4 } + ] as SessionMessage[] + + const messages = toChatMessages(stored).map(toRuntimeMessage) + + const { container, rerender, unmount } = render( + + ) + + const original = container.querySelector('[data-role="assistant"]') + + rerender() + await waitFor(() => expect(container.textContent).toContain('The deployment is verified.')) + expect(container.querySelectorAll('[data-slot="aui_turn-pair"]')).toHaveLength(1) + expect(container.querySelectorAll('[data-slot="aui_response-group"]')).toHaveLength(1) + expect(container.querySelectorAll('[data-slot="aui_msg-actions"]')).toHaveLength(1) + expect(container.querySelector('[data-role="assistant"]')).toBe(original) + expect([...container.querySelectorAll('[data-role="assistant"]')].map(e => e.getAttribute('data-message-id'))) + .toEqual([messages[1]!.id, messages[3]!.id]) + const actions = container.querySelector('[data-slot="aui_msg-actions"]') as HTMLElement + fireEvent.click(within(actions).getByRole('button', { name: 'Copy' })) + await waitFor(() => expect(clipboard.writeText).toHaveBeenLastCalledWith('Checking the deployment.\n\nThe deployment is verified.')) + fireEvent.click(within(actions).getByRole('button', { name: /branch/i })) + expect(branch).toHaveBeenLastCalledWith(messages[3]!.id) + + const pending: ThreadMessage[] = [ + ...messages, + { ...messages[3]!, role: 'assistant', id: 'next', content: [], status: { type: 'running' } } as ThreadMessage + ] + + rerender() + await waitFor(() => expect(container.querySelectorAll('[data-role="assistant"]')).toHaveLength(3)) + expect(container.querySelectorAll('[data-slot="aui_msg-actions"]')).toHaveLength(1) + + unmount() + const reloaded = render() + expect(reloaded.container.querySelectorAll('[data-slot="aui_msg-actions"]')).toHaveLength(1) + reloaded.unmount() + } +}) + +it('ends the response at a real user prompt or unrelated system event', () => { + const messages = toChatMessages([ + { role: 'user', content: 'First question', timestamp: 1 }, + { role: 'assistant', content: 'First answer', timestamp: 2 }, + { role: 'system', content: 'slash:/model\nmodel changed', timestamp: 3 }, + { role: 'assistant', content: 'Separate answer', timestamp: 4 }, + { role: 'user', content: 'Second question', timestamp: 5 }, + { role: 'assistant', content: 'Second answer', timestamp: 6 } + ]).map(toRuntimeMessage) + + const { container } = render() + expect(container.querySelectorAll('[data-slot="aui_turn-pair"]')).toHaveLength(2) + expect(container.querySelectorAll('[data-slot="aui_msg-actions"]')).toHaveLength(3) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/response-group.tsx b/apps/desktop/src/components/assistant-ui/thread/response-group.tsx new file mode 100644 index 0000000000..7616568aa0 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/response-group.tsx @@ -0,0 +1,98 @@ +import { ThreadPrimitive, useAuiState } from '@assistant-ui/react' +import { type ComponentProps, createContext, useMemo } from 'react' + +import { contentHasVisibleText, messageContentText, PROCESS_NOTIFICATION_RE } from './content' + +interface GroupMessage { + role: string + content: unknown + metadata?: { custom?: Record } +} + +/** Background deliveries continue the response without becoming human prompts. */ +export function responseMessageRole(message: GroupMessage): string { + const custom = message.metadata?.custom + + const background = + message.role === 'system' + ? Boolean(custom?.asyncResult || custom?.asyncResultKind) + : message.role === 'user' && PROCESS_NOTIFICATION_RE.test(messageContentText(message.content)) + + return background ? 'background' : message.role +} + +export const ResponseMessageIds = createContext([]) + +interface ResponseMessagesProps { + components: ComponentProps['components'] + indices: readonly number[] +} + +interface ResponseRow { + index: number + id: string + role: string + hasText: boolean +} + +interface ResponseSection { + key: string + indices: number[] + assistantIds: string[] + response: boolean +} + +/** Keep message runtimes intact; only their visual container and footer are shared. */ +export function ResponseMessages({ components, indices }: ResponseMessagesProps) { + const signature = useAuiState(s => + JSON.stringify( + indices.flatMap(index => { + const message = s.thread.messages[index] + + // A history replacement can notify this row before its parent updates indices. + return message + ? [{ index, id: message.id, role: responseMessageRole(message), hasText: contentHasVisibleText(message.content) }] + : [] + }) + ) + ) + + const sections = useMemo(() => { + const rows = JSON.parse(signature) as ResponseRow[] + const result: ResponseSection[] = [] + + for (const row of rows) { + const response = row.role === 'assistant' || row.role === 'background' + const previous = result.at(-1) + + const section: ResponseSection = + response && previous?.response ? previous : { key: row.id, indices: [], assistantIds: [], response } + + if (section !== previous) { + result.push(section) + } + + section.indices.push(row.index) + + if (row.role === 'assistant' && row.hasText) { + section.assistantIds.push(row.id) + } + } + + return result + }, [signature]) + + return sections.map(section => + section.response ? ( + +
+ {section.indices.map(index => ( + + ))} +
+
+ ) : ( + + ) + ) +} diff --git a/apps/desktop/src/components/assistant-ui/thread/system-message.tsx b/apps/desktop/src/components/assistant-ui/thread/system-message.tsx index 1bdf627548..e643e159cf 100644 --- a/apps/desktop/src/components/assistant-ui/thread/system-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/system-message.tsx @@ -4,8 +4,9 @@ import { type FC, useState } from 'react' import { MarkdownTextContent } from '@/components/assistant-ui/markdown-text' import { messageContentText } from '@/components/assistant-ui/thread/content' import { MessageTimelineTimestamp } from '@/components/assistant-ui/thread/timeline-timestamp' -import { SCAFFOLD_LABEL_CLASS, ScaffoldRow } from '@/components/chat/scaffold-row' +import { SCAFFOLD_GLYPH_CLASS, SCAFFOLD_LABEL_CLASS, ScaffoldRow } from '@/components/chat/scaffold-row' import { Codicon } from '@/components/ui/codicon' +import { LogView } from '@/components/ui/log-view' import { ToolIcon } from '@/components/ui/tool-icon' import { LinkifiedText } from '@/lib/external-link' import { cn } from '@/lib/utils' @@ -14,41 +15,68 @@ const SLASH_STATUS_RE = /^slash:(?\/[^\n]+)\n(?[\s\S]*)$/ const STEER_NOTE_RE = /^steer:(?[\s\S]+)$/ const REVIEW_NOTE_RE = /^review:(?