fix(desktop): keep background continuations in one visual response
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user