fix(desktop): keep background continuations in one visual response

This commit is contained in:
brooklyn!
2026-09-15 23:41:02 -07:00
parent 57d34b14c2
commit 3bdd4cc5fd
14 changed files with 332 additions and 70 deletions
@@ -157,6 +157,7 @@ const _chatMessageFieldsExhaustive: {
const COMPARED_FIELDS = [
'asyncResult',
'asyncResultKind',
'id',
'role',
'pending',
@@ -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<AssistantMessageProps & { collapsedNotice?: null
}) => {
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<AssistantMessageProps & { collapsedNotice?: null
// stable across the 30 Hz delta stream, so this adds no per-token renders).
const turnDurationS = useAuiState(s => 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<AssistantMessageProps & { collapsedNotice?: null
</MessagePrimitive.Error>
</div>
<MessageTimelineTimestamp className="px-(--message-text-indent) pt-0.5" suppressIfDuplicatePart />
{hasVisibleText && !isInterim && (
{hasVisibleText && !isInterim && responseTail && (
<AssistantFooter
durationS={turnDurationS}
getMessageText={getMessageText}
@@ -1,5 +1,7 @@
const EMPTY_ATTACHMENT_REFS: string[] = []
export const PROCESS_NOTIFICATION_RE = /^\[IMPORTANT: Background process [\s\S]*\]$/
export function partText(part: unknown): string {
if (typeof part === 'string') {
return part
@@ -43,6 +43,7 @@ import { isSecondaryWindow } from '@/store/windows'
import { MessageRenderBoundary } from '../message-render-boundary'
import { responseMessageRole, ResponseMessages } from './response-group'
import { resolveShowEarlierAction, shouldAutoShowEarlier, useTranscriptWindow } from './transcript-window'
import { useMessagesBelow } from './use-messages-below'
import { useStickyPromptClip } from './use-sticky-prompt-clip'
@@ -406,9 +407,7 @@ const TurnRow = memo(function TurnRow({ components, group, resetKey, virtualized
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
<ResponseMessages components={components} indices={group.indices} />
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
@@ -436,7 +435,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
// 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 =>
@@ -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(
<ThreadRuntime messages={messages}>
<Thread />
</ThreadRuntime>
)
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()
}
})
@@ -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(
<ThreadRuntime messages={messages.slice(0, 2)}><Thread onBranchInNewChat={branch} /></ThreadRuntime>
)
const original = container.querySelector('[data-role="assistant"]')
rerender(<ThreadRuntime messages={messages}><Thread onBranchInNewChat={branch} /></ThreadRuntime>)
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(<ThreadRuntime messages={pending}><Thread onBranchInNewChat={branch} /></ThreadRuntime>)
await waitFor(() => expect(container.querySelectorAll('[data-role="assistant"]')).toHaveLength(3))
expect(container.querySelectorAll('[data-slot="aui_msg-actions"]')).toHaveLength(1)
unmount()
const reloaded = render(<ThreadRuntime messages={messages}><Thread /></ThreadRuntime>)
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(<ThreadRuntime messages={messages}><Thread /></ThreadRuntime>)
expect(container.querySelectorAll('[data-slot="aui_turn-pair"]')).toHaveLength(2)
expect(container.querySelectorAll('[data-slot="aui_msg-actions"]')).toHaveLength(3)
})
@@ -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<string, unknown> }
}
/** 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<readonly string[]>([])
interface ResponseMessagesProps {
components: ComponentProps<typeof ThreadPrimitive.MessageByIndex>['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 ? (
<ResponseMessageIds.Provider key={section.key} value={section.assistantIds}>
<div className="group flex min-w-0 flex-col gap-(--scaffold-block-gap)" data-slot="aui_response-group">
{section.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
</ResponseMessageIds.Provider>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={section.indices[0]!} key={section.key} />
)
)
}
@@ -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:(?<command>\/[^\n]+)\n(?<output>[\s\S]*)$/
const STEER_NOTE_RE = /^steer:(?<text>[\s\S]+)$/
const REVIEW_NOTE_RE = /^review:(?<label>[^:\n]+):?\s*(?<detail>[\s\S]*)$/
interface BackgroundResultProps {
text: string
report: string
process?: boolean
}
export const BackgroundResult: FC<BackgroundResultProps> = ({ text, report, process }) => {
const [open, setOpen] = useState(false)
return (
<div
className="flex w-full min-w-0 flex-col self-start py-1 pl-(--message-text-indent)"
data-slot="aui_background-result"
>
<div data-conversation-scaffold="">
<ScaffoldRow
onToggle={report ? () => setOpen(!open) : undefined}
open={open}
trailing={
<>
{' '}
<MessageTimelineTimestamp />
</>
}
>
{process && (
<span className={SCAFFOLD_GLYPH_CLASS}>
<ToolIcon className="text-(--ui-text-tertiary)" name="terminal" size="0.875rem" />
</span>
)}
<span className={cn(SCAFFOLD_LABEL_CLASS, 'min-w-0 truncate')}>{text}</span>
</ScaffoldRow>
</div>
{open &&
(process ? (
<LogView className="mt-2 max-h-80 overscroll-contain">{report}</LogView>
) : (
<div className="mt-2 max-h-80 min-w-0 max-w-full overflow-auto overscroll-contain wrap-anywhere">
<MarkdownTextContent isRunning={false} text={report} />
</div>
))}
</div>
)
}
export const SystemMessage: FC = () => {
const text = useAuiState(s => messageContentText(s.message.content))
const asyncResult = useAuiState(s => s.message.metadata.custom?.asyncResult)
const [reportOpen, setReportOpen] = useState(false)
const processResult = useAuiState(s => s.message.metadata.custom?.asyncResultKind === 'process')
if (!text) {
return null
}
if (typeof asyncResult === 'string' && asyncResult) {
if (processResult || (typeof asyncResult === 'string' && asyncResult)) {
return (
<MessagePrimitive.Root
className="flex w-full min-w-0 flex-col self-start py-1"
data-role="system"
data-slot="aui_system-message-root"
>
<div data-conversation-scaffold="">
<ScaffoldRow
onToggle={() => setReportOpen(!reportOpen)}
open={reportOpen}
trailing={
<>
{' '}
<MessageTimelineTimestamp />
</>
}
>
<span className={SCAFFOLD_LABEL_CLASS}>{text}</span>
</ScaffoldRow>
</div>
{reportOpen && (
<div className="mt-2 max-h-80 min-w-0 max-w-full overflow-auto overscroll-contain wrap-anywhere">
<MarkdownTextContent isRunning={false} text={asyncResult} />
</div>
)}
<MessagePrimitive.Root className="w-full min-w-0 self-start" data-role="system" data-slot="aui_system-message-root">
<BackgroundResult
process={processResult}
report={typeof asyncResult === 'string' ? asyncResult : ''}
text={text}
/>
</MessagePrimitive.Root>
)
}
@@ -1,4 +1,5 @@
// Pure timeline helpers — no React/DOM; tested in thread-timeline-data.test.ts.
import { PROCESS_NOTIFICATION_RE } from './content'
export interface TimelineSourceMessage {
id: string
@@ -11,9 +12,6 @@ export interface TimelineEntry {
preview: string
}
// Injected as user messages for alternation; not human prompts (thread.tsx).
const PROCESS_NOTIFICATION_RE = /^\[IMPORTANT: Background process [\s\S]*\]$/
const PREVIEW_MAX = 120
export function timelinePreview(text: string, max: number = PREVIEW_MAX): string {
@@ -2,8 +2,9 @@ import { ActionBarPrimitive, BranchPickerPrimitive, MessagePrimitive, useAuiStat
import { type FC, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
import { messageAttachmentRefs, messageContentText } from '@/components/assistant-ui/thread/content'
import { messageAttachmentRefs, messageContentText, PROCESS_NOTIFICATION_RE } from '@/components/assistant-ui/thread/content'
import { ReactionBadge, ReactionPicker } from '@/components/assistant-ui/thread/message-reactions'
import { BackgroundResult } from '@/components/assistant-ui/thread/system-message'
import { MessageTimelineTimestamp } from '@/components/assistant-ui/thread/timeline-timestamp'
import { type RestoreMessageTarget } from '@/components/assistant-ui/thread/types'
import { useMessageReactions } from '@/components/assistant-ui/thread/use-message-reactions'
@@ -72,13 +73,6 @@ export const USER_ACTION_ICON_BUTTON_CLASS =
export const USER_ACTION_ICON_SIZE = '0.6875rem'
export const StopGlyph = <StopFilled aria-hidden className="size-3.5 -translate-y-px" />
// Background-process notifications are injected into the conversation as user
// messages (the agent must react to them, and message-role alternation forbids
// a synthetic system row mid-loop). They are NOT something the human typed, so
// render them as a compact system-style notice instead of a user bubble.
// Shape: see tools/process_registry.py format_process_notification().
const PROCESS_NOTIFICATION_RE = /^\[IMPORTANT: Background process [\s\S]*\]$/
// Agent-to-agent deliveries ("Message from 🤖 <sender>: …", the Bot Mode /
// multi-profile convention; optional "(@<handle>)" carries the sender's
// profile name for avatar resolution; legacy "[Message from agent
@@ -237,27 +231,7 @@ const ProcessNotificationNote: FC<{ text: string }> = ({ text }) => {
const headline = (newline === -1 ? body : body.slice(0, newline)).trim()
const detail = newline === -1 ? '' : body.slice(newline + 1).trim()
return (
<div className="flex max-w-[min(86%,44rem)] flex-col gap-0.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60">
<span className="flex items-center gap-1.5">
<Codicon className="shrink-0 text-muted-foreground/55" name="terminal" size="0.75rem" />
<span className="wrap-anywhere">{headline}</span>
</span>
{detail && (
<details className="pl-[1.3125rem]">
<summary className="cursor-pointer select-none text-muted-foreground/45 hover:text-muted-foreground/70">
output
</summary>
<pre
className="mt-0.5 max-h-48 overflow-auto whitespace-pre-wrap font-mono text-[0.625rem] leading-4 text-muted-foreground/55"
data-selectable-text="true"
>
{detail}
</pre>
</details>
)}
</div>
)
return <BackgroundResult process report={detail} text={headline} />
}
export const UserMessage: FC<{
@@ -379,7 +353,6 @@ export const UserMessage: FC<{
data-slot="aui_user-message-root"
>
<ProcessNotificationNote text={messageText.trim()} />
<MessageTimelineTimestamp className="self-center" />
</MessagePrimitive.Root>
)
}
@@ -432,6 +432,7 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
...(message.display_kind === 'async_delegation_complete' || message.display_kind === 'process_complete'
? { asyncResult: asyncResultBody(displayContentForMessage(message.role, message.content || content)) }
: {}),
...(message.display_kind === 'process_complete' ? { asyncResultKind: 'process' as const } : {}),
timestamp: earliestTimestamp(message.timestamp, ...parts.map(part => part.timestamp)),
...(rowId !== undefined ? { rowId } : {}),
...(reactions.length ? { reactions } : {}),
@@ -22,6 +22,7 @@ export type ChatMessage = {
parts: ChatMessagePart[]
/** Result body only; the system text remains the compact completion label. */
asyncResult?: string
asyncResultKind?: 'process'
timestamp?: number
completedAt?: number
pending?: boolean
+7 -1
View File
@@ -413,7 +413,13 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
role,
content: [textPart(text)],
createdAt,
metadata: { custom: { ...timelineMeta, ...(message.asyncResult ? { asyncResult: message.asyncResult } : {}) } }
metadata: {
custom: {
...timelineMeta,
...(message.asyncResult ? { asyncResult: message.asyncResult } : {}),
...(message.asyncResultKind ? { asyncResultKind: message.asyncResultKind } : {})
}
}
} as ThreadMessage
}
+6 -2
View File
@@ -1996,7 +1996,9 @@ body.guest-pointer-lock :is(webview, iframe) {
one row (an open diff) could not stay lit while its siblings dimmed. Hence
the tool group wrapper carries no mark and each row inside it carries its
own — no two marked elements nest, so the fade never compounds. */
[data-slot='aui_assistant-message-content'] [data-conversation-scaffold] {
[data-slot='aui_assistant-message-content'] [data-conversation-scaffold],
[data-slot='aui_background-result'] [data-conversation-scaffold],
[data-slot='aui_background-resume'] {
opacity: 0.67;
transition: opacity 120ms ease-out;
}
@@ -2004,7 +2006,9 @@ body.guest-pointer-lock :is(webview, iframe) {
/* Lift on hover or *keyboard* focus only. `:focus-within` also matches the
focus a mouse click leaves on the disclosure toggle, which kept a row lit
after you clicked to collapse it; `:has(:focus-visible)` excludes that. */
[data-slot='aui_assistant-message-content'] [data-conversation-scaffold]:is(:hover, :has(:focus-visible)) {
[data-slot='aui_assistant-message-content'] [data-conversation-scaffold]:is(:hover, :has(:focus-visible)),
[data-slot='aui_background-result'] [data-conversation-scaffold]:is(:hover, :has(:focus-visible)),
[data-slot='aui_background-resume']:is(:hover, :has(:focus-visible)) {
opacity: 1;
}