Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { deliveryTargetFromCommand, replyTextFromResult } from './agent-delivery'
// Sender-side inter-agent deliveries render as "Messaged X" / "Message from
// X" notices instead of terminal transcript rows. This pins the detection
// (the canonical Bot Mode command shape) and the reply extraction.
describe('delivery command detection', () => {
it('matches the canonical delivery command', () => {
const cmd = 'hermes -p turqoise chat --in ~ -c "Bot Chat" -Q -q "Message from 🤖 Hermes (@hermes): hi there"'
expect(deliveryTargetFromCommand(cmd)).toBe('turqoise')
})
it('matches with a cd prefix and timeout wrapper', () => {
const cmd = 'cd ~ && timeout 240 hermes -p mr-tester chat --in "~" -Q -q "Message from 🤖 Hermes: hello"'
expect(deliveryTargetFromCommand(cmd)).toBe('mr-tester')
})
it('ignores ordinary terminal commands', () => {
expect(deliveryTargetFromCommand('ls -la')).toBeNull()
expect(deliveryTargetFromCommand('hermes -p turqoise chat -q "plain question"')).toBeNull()
expect(deliveryTargetFromCommand('hermes sessions list')).toBeNull()
})
})
describe('reply extraction', () => {
it('strips session_id bookkeeping and keeps the reply', () => {
const output = 'session_id: 20260813_220347_f69ac6\nHi Hermes! Good to hear from you.'
expect(replyTextFromResult({ output })).toBe('Hi Hermes! Good to hear from you.')
})
it('unwraps JSON-shaped terminal results', () => {
const result = JSON.stringify({ exit_code: 0, output: 'session_id: abc\nack' })
expect(replyTextFromResult(result)).toBe('ack')
})
it('returns empty for empty results', () => {
expect(replyTextFromResult(undefined)).toBe('')
expect(replyTextFromResult({ output: '' })).toBe('')
})
})
@@ -0,0 +1,132 @@
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
import { type FC, useEffect, useState } from 'react'
import { AGENT_MESSAGE_RE, agentAvatarCache, resolveAgentAvatar } from '@/components/assistant-ui/thread/user-message'
// Sender-side inter-agent delivery: `hermes -p <agent> chat … -q "Message
// from 🤖 <sender>…"` run through the terminal tool IS the messaging
// pipeline (the Bot Mode / multi-profile convention shipped with #85855).
// Rendering it as a terminal transcript makes the sending bot's chat read
// like ops tooling; the user-facing truth is "Messaged X" and, when the
// quiet run returns the recipient's reply, "Message from X" — the same
// compact event notices the receiving chat shows.
const DELIVERY_COMMAND_RE =
/(?:^|[;&|]\s*|\bhermes\s+)-p\s+("?)([a-z0-9][a-z0-9_-]{0,63})\1\s+chat\b[\s\S]*?-q\s+["']Message from/iu
export function deliveryTargetFromCommand(command: string): null | string {
const match = DELIVERY_COMMAND_RE.exec(command)
return match ? match[2].toLowerCase() : null
}
/** Extract the recipient's reply text from the terminal result payload. */
export function replyTextFromResult(result: unknown): string {
const container = (result ?? {}) as { content?: unknown; output?: unknown }
let raw = ''
if (typeof result === 'string') {
raw = result
} else if (typeof container.output === 'string') {
raw = container.output
} else if (Array.isArray(container.content)) {
raw = container.content
.map(entry => (typeof (entry as { text?: unknown })?.text === 'string' ? (entry as { text: string }).text : ''))
.join('\n')
}
// Terminal results may be JSON-wrapped: {"output": "...", "exit_code": 0}
if (raw.trimStart().startsWith('{')) {
try {
const parsed = JSON.parse(raw) as { output?: unknown }
if (typeof parsed.output === 'string') {
raw = parsed.output
}
} catch {
/* not JSON — use as-is */
}
}
// Drop session_id bookkeeping lines; what remains is the reply.
return raw
.split('\n')
.filter(line => !/^session_id:\s/.test(line.trim()))
.join('\n')
.trim()
}
const NOTICE_CLASS =
'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'
const AgentGlyph: FC<{ handle: string }> = ({ handle }) => {
const [avatar, setAvatar] = useState<null | string>(() => agentAvatarCache.get(handle.toLowerCase()) ?? null)
useEffect(() => {
let live = true
void resolveAgentAvatar(handle).then(url => {
if (live && url) {
setAvatar(url)
}
})
return () => {
live = false
}
}, [handle])
return avatar ? (
<img alt="" aria-hidden className="size-4 shrink-0 rounded-full object-cover" src={avatar} />
) : (
<span aria-hidden className="text-[0.8125rem] leading-none">
🤖
</span>
)
}
/** "Messaged X" (+ "Message from X" once the reply lands) for a delivery
* command run via the terminal tool. Returns null when the command is not
* a delivery — caller falls through to the normal terminal row. */
export const AgentDeliveryNotice: FC<ToolCallMessagePartProps> = props => {
const command = typeof props.args?.command === 'string' ? props.args.command : ''
const target = deliveryTargetFromCommand(command)
if (!target || props.isError) {
return null
}
const pending = props.result === undefined
const reply = pending ? '' : replyTextFromResult(props.result)
// Strip a leading agent-message prefix if the recipient echoed one back.
const replyBody = AGENT_MESSAGE_RE.exec(reply)?.[4] ?? reply
return (
<div className="flex w-full min-w-0 flex-col items-stretch gap-0.5">
<div className={NOTICE_CLASS} data-slot="aui_agent-delivery-notice">
<span className="flex items-center justify-center gap-1.5">
<AgentGlyph handle={target} />
<span className="wrap-anywhere">
{pending ? 'Messaging' : 'Messaged'} {target}
{pending ? '…' : ''}
</span>
</span>
</div>
{!pending && replyBody && (
<div className={NOTICE_CLASS} data-slot="aui_agent-reply-notice">
<span className="flex items-center justify-center gap-1.5">
<AgentGlyph handle={target} />
<span className="wrap-anywhere">Message from {target}</span>
</span>
<details className="self-center">
<summary className="cursor-pointer select-none text-center text-muted-foreground/45 hover:text-muted-foreground/70">
show message
</summary>
<div className="mt-1 max-w-[36rem] whitespace-pre-wrap rounded-lg border border-(--ui-stroke-tertiary) px-3 py-2 text-left text-[0.75rem] leading-5 text-foreground/85">
{replyBody}
</div>
</details>
</div>
)}
</div>
)
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { AGENT_MESSAGE_RE } from './user-message'
// Agent-to-agent deliveries render as a compact attributed timeline notice,
// not a user bubble. This pins the detection contract: the Bot Mode prefix
// ("Message from 🤖 <sender>: …"), the handle-carrying form
// ("Message from 🤖 <sender> (@<handle>): …"), and the legacy bracket form
// all match; human prose that merely mentions the phrase does not.
describe('agent message detection', () => {
it('matches the Bot Mode delivery prefix with sender and body', () => {
const m = AGENT_MESSAGE_RE.exec('Message from 🤖 Hermes: hello there')
expect(m?.[1]?.trim()).toBe('Hermes')
expect(m?.[4]).toBe('hello there')
})
it('captures the @handle when present', () => {
const m = AGENT_MESSAGE_RE.exec('Message from 🤖 Eats Tests (@mr-tester): run them all')
expect(m?.[1]?.trim()).toBe('Eats Tests')
expect(m?.[2]).toBe('mr-tester')
expect(m?.[4]).toBe('run them all')
})
it('matches without the robot emoji', () => {
const m = AGENT_MESSAGE_RE.exec('Message from Turquoise: ready to work')
expect(m?.[1]?.trim()).toBe('Turquoise')
expect(m?.[4]).toBe('ready to work')
})
it('matches the legacy bracket form', () => {
const m = AGENT_MESSAGE_RE.exec("[Message from agent 'turqoise'] ping")
expect(m?.[3]).toBe('turqoise')
expect(m?.[4]).toBe('ping')
})
it('spans multi-line bodies', () => {
const m = AGENT_MESSAGE_RE.exec('Message from 🤖 Dev: line one\nline two')
expect(m?.[4]).toBe('line one\nline two')
})
it('does not match prose that merely contains the phrase', () => {
expect(AGENT_MESSAGE_RE.test('I got a Message from 🤖 Hermes: earlier')).toBe(false)
expect(AGENT_MESSAGE_RE.test('can you explain what Message from means?')).toBe(false)
})
})
@@ -0,0 +1,146 @@
// Bug #2: the Branch-in-new-chat button used to render unconditionally even
// when its handler was a no-op (session-tile.tsx passed `() => undefined`
// for branched/tiled chats, where nested branching isn't supported). That
// left a visibly clickable button that silently did nothing. The fix makes
// AssistantMessage's action bar hide the button entirely when no handler is
// supplied, matching how onDismissError/onRestoreToMessage already behave.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { $displayTimestamps } from '@/store/display-timestamps'
import { stubThreadEnvironment } from '../test-utils'
import { formatTimelineRange, formatTimelineTimestamp } from './timestamp'
import { Thread } from '.'
// Timeline timestamps render only when `display.timestamps` is enabled.
$displayTimestamps.set(true)
const createdAt = new Date('2026-05-01T00:00:00.000Z')
const completedAt = createdAt.getTime() / 1000 + 1.25
stubThreadEnvironment()
afterEach(() => {
cleanup()
})
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'question one' }],
attachments: [],
createdAt,
metadata: { custom: { timelineTimestamp: createdAt.getTime() / 1000 } }
} as unknown as ThreadMessage
}
function assistantMessage(): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [
{
type: 'reasoning',
text: 'checked carefully',
timestamp: createdAt.getTime() / 1000 + 0.05,
completedAt: createdAt.getTime() / 1000 + 0.1
},
{
type: 'text',
text: 'done',
timestamp: createdAt.getTime() / 1000 + 0.125,
completedAt: createdAt.getTime() / 1000 + 0.5
}
],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: { timelineCompletedAt: completedAt, timelineTimestamp: createdAt.getTime() / 1000 }
}
} as unknown as ThreadMessage
}
function Harness({
assistant = assistantMessage(),
onBranchInNewChat
}: {
assistant?: ThreadMessage
onBranchInNewChat?: (messageId: string) => void
}) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistant],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread onBranchInNewChat={onBranchInNewChat} />
</AssistantRuntimeProvider>
)
}
describe('AssistantMessage branch button visibility (bug #2 fix)', () => {
it('shows the Branch in new chat button when a handler is provided (open chat)', async () => {
render(<Harness onBranchInNewChat={() => undefined} />)
expect(await screen.findByRole('button', { name: 'Branch in new chat' })).toBeTruthy()
})
it('hides the Branch in new chat button when no handler is provided (session-tile / branched chat)', async () => {
render(<Harness />)
// Wait for the assistant message to actually mount before asserting
// absence, so a missing button isn't just a false negative from an
// unrendered message.
await screen.findByText('done')
expect(screen.queryByRole('button', { name: 'Branch in new chat' })).toBeNull()
})
})
describe('message timeline timestamps', () => {
it('always renders precise user and assistant lifecycle times', async () => {
const { container } = render(<Harness />)
await screen.findByText('done')
const stamps = Array.from(container.querySelectorAll('[data-slot="timeline-timestamp"]')).map(node =>
node.textContent?.trim()
)
const startedAt = createdAt.getTime() / 1000
expect(stamps).toContain(formatTimelineTimestamp(startedAt))
expect(stamps).toContain(formatTimelineRange(startedAt, completedAt))
expect(stamps).toContain(formatTimelineRange(startedAt + 0.05, startedAt + 0.1))
expect(stamps).toContain(formatTimelineRange(startedAt + 0.125, startedAt + 0.5))
})
it('suppresses an aggregate assistant stamp that exactly duplicates its sole part', async () => {
const startedAt = createdAt.getTime() / 1000
const assistant = {
...assistantMessage(),
content: [{ completedAt, text: 'done', timestamp: startedAt, type: 'text' }]
} as unknown as ThreadMessage
const { container } = render(<Harness assistant={assistant} />)
await screen.findByText('done')
const stamps = Array.from(container.querySelectorAll('[data-slot="timeline-timestamp"]')).map(node =>
node.textContent?.trim()
)
expect(stamps.filter(stamp => stamp === formatTimelineRange(startedAt, completedAt))).toHaveLength(1)
})
})
@@ -0,0 +1,749 @@
import {
ActionBarPrimitive,
BranchPickerPrimitive,
ErrorPrimitive,
MessagePrimitive,
useAuiState,
useMessageRuntime
} from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type FC, type ReactNode, useCallback, useMemo, useState } from 'react'
import { useInRouterContext, useNavigate } from 'react-router'
import { useSessionView } from '@/app/chat/session-view'
import { SETTINGS_ROUTE } from '@/app/routes'
import { ChangedFilesCard } from '@/components/assistant-ui/thread/changed-files-card'
import {
contentHasVisibleText,
messageContentText,
pickPrimaryPreviewTarget
} 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 { 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'
import { AGENT_MESSAGE_RE } from '@/components/assistant-ui/thread/user-message'
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
import { formatElapsed } from '@/components/chat/activity-timer'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { useI18n } from '@/i18n'
import { type ErrorSurface, formatErrorDiagnostics } from '@/lib/error-surface'
import { triggerHaptic } from '@/lib/haptics'
import {
AudioLines,
GitForkIcon,
Loader2Icon,
RefreshCwIcon,
SmilePlusIcon,
Upload,
VolumeXIcon,
XIcon
} from '@/lib/icons'
import { extractPreviewTargets } from '@/lib/preview-targets'
import { markAssistantIdSpoken } from '@/lib/spoken-reply'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import { notifyError } from '@/store/notifications'
import { requestSendDiagnostics } from '@/store/send-diagnostics'
import { $connection, $currentModel } from '@/store/session'
import { $voicePlayback } from '@/store/voice-playback'
// Stable empty identity for the settled-parts selector — a fresh [] per render
// would re-derive the changed-files card on every message re-render.
const EMPTY_PARTS: readonly unknown[] = []
// PERF: hoisted to module scope so the element OBJECT is identical on every
// render of every assistant message. React bails out of re-rendering a child
// whose element identity is unchanged, so a status flip on the message root
// (pending -> complete and back, N rows per stream flush) can no longer
// descend into the parts subtree at all. Its props were already the module
// constant MESSAGE_PARTS_COMPONENTS, so nothing per-message is captured here.
const MESSAGE_PARTS = <MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
interface MessageActionProps {
messageId: string
/** Lazy accessor — reads the live message text at action time. Passing the
* text itself as a prop forces the whole footer to re-render on every
* streaming delta flush (the text changes ~30×/s), which profiling showed
* was a large slice of per-token script time on long transcripts. */
getMessageText: () => string
onBranchInNewChat?: (messageId: string) => void
}
interface AssistantMessageProps {
onBranchInNewChat?: (messageId: string) => void
onDismissError?: (messageId: string) => void
}
export const AssistantMessage: FC<AssistantMessageProps> = props => {
// A reply to an inter-agent delivery is part of that exchange, not part of
// the human conversation — collapse it under a compact notice ("Reply to
// <sender>", expandable), mirroring the sender-side notice the previous
// user message already renders as. Grok-bots parity: the transcript shows
// events; the texts are one click away. Detection: the immediately
// preceding user message matches AGENT_MESSAGE_RE.
const interAgentSender = useAuiState(s => {
const messages = s.thread.messages
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].id !== s.message.id) {
continue
}
for (let j = i - 1; j >= 0; j--) {
const prev = messages[j] as { content?: unknown; role?: string }
if (prev.role === 'assistant') {
return null
}
if (prev.role === 'user') {
const match = AGENT_MESSAGE_RE.exec(messageContentText(prev.content as never).trim())
return match ? (match[1] || match[3] || 'agent').trim() : null
}
}
return null
}
return null
})
// The collapse gate below needs the LIVE running status, but only an
// inter-agent reply can ever be collapsed. Dispatching on that first keeps
// the status subscription out of the standard path entirely — the standard
// message root now re-renders for content, never for a pending flip.
return interAgentSender ? (
<InterAgentAssistantMessage {...props} sender={interAgentSender} />
) : (
<AssistantMessageBody {...props} />
)
}
/** The compact stand-in a settled inter-agent reply collapses to (Grok-bots
* parity — the transcript shows the event; the text is one click away). */
const InterAgentCollapsedNotice: FC<{ sender: string }> = ({ sender }) => (
<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 justify-center gap-1.5">
<Codicon className="shrink-0 text-muted-foreground/55" name="arrow-small-right" size="0.8125rem" />
<span className="wrap-anywhere">Replied to {sender}</span>
</span>
<details className="self-center">
<summary className="cursor-pointer select-none text-center text-muted-foreground/45 hover:text-muted-foreground/70">
show reply
</summary>
<div className="mt-1 max-w-[36rem] rounded-lg border border-(--ui-stroke-tertiary) px-3 py-2 text-left text-[0.75rem] leading-5 text-foreground/85">
{MESSAGE_PARTS}
</div>
</details>
</div>
)
/**
* An assistant reply that answers an inter-agent delivery. Owns the only
* root-level `isRunning` subscription left in this file, and it is confined to
* the rare inter-agent case: the reply renders collapsed once it settles, so
* the gate genuinely needs live status. Never collapse while streaming — the
* user should see progress.
*
* The collapse is expressed as a CHILD of the normal body, not as a competing
* root. Returning a bare MessagePrimitive.Root here for the settled case put a
* different element type in this position than the running case
* (AssistantMessageBody), so settling unmounted the whole row and mounted a
* fresh one — throwing away the DOM the scroll anchor was holding, which can
* jump the transcript under the reader. One component, one root, children
* vary: settling is now a prop change React applies in place.
*/
const InterAgentAssistantMessage: FC<AssistantMessageProps & { sender: string }> = ({ sender, ...props }) => {
const isRunning = useAuiState(s => s.message.status?.type === 'running')
return (
<AssistantMessageBody
{...props}
collapsedNotice={isRunning ? null : <InterAgentCollapsedNotice sender={sender} />}
/>
)
}
const AssistantMessageBody: FC<AssistantMessageProps & { collapsedNotice?: null | ReactNode }> = ({
collapsedNotice = null,
onBranchInNewChat,
onDismissError
}) => {
const messageId = useAuiState(s => s.message.id)
const messageRuntime = useMessageRuntime()
const { t } = useI18n()
// PERF: this component must NOT subscribe to the streaming text, and no
// longer subscribes to the streaming STATUS either. Every selector here
// returns a value that stays referentially stable across token flushes
// (booleans, '' while running), so the 30 Hz delta stream only re-renders
// the markdown part and the tiny status leaves — not the footer, the
// preview block, or this root.
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)
// Whole-turn wall-clock seconds (set once at completion — referentially
// 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])
// useEnterAnimation consults `enabled` ONLY when its callback ref fires,
// i.e. at mount: the hook parks the value in a ref and returns a
// useCallback([]) identity, and its own contract is "`enabled` is captured
// at mount-time only — flipping it later doesn't suddenly play the animation
// on existing nodes" (see lib/use-enter-animation.ts). So a live
// subscription here would re-render this root on every pending flip to feed
// a value the hook already ignores. Capture it once, off the runtime, with
// no subscription at all.
const [initiallyRunning] = useState(() => messageRuntime.getState().status?.type === 'running')
const enterRef = useEnterAnimation(initiallyRunning, `assistant-message:${messageId}`)
// Double-click the reply to heart it (iMessage). Undefined while reactions
// are off, so the root carries no listener at all.
const onDoubleClick = useTapbackDoubleClick(messageId, 'assistant')
return (
<MessagePrimitive.Root
className={cn(
'group flex w-full min-w-0 max-w-full flex-col gap-0 self-start overflow-hidden',
collapsedNotice && 'pb-(--conversation-turn-gap)'
)}
data-role="assistant"
data-slot="aui_assistant-message-root"
// Collapsed inter-agent rows never carried the tapback listener; keeping
// that exact truth table means gating it on the notice rather than on
// whether the hook returned a handler.
onDoubleClick={collapsedNotice ? undefined : onDoubleClick}
ref={enterRef}
>
{collapsedNotice ?? (
<>
<div
className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground"
data-slot="aui_assistant-message-content"
>
{/* Todos render in the composer status stack now, not inline. */}
{MESSAGE_PARTS}
<AssistantStatusSlot />
<AssistantPreviewEmbeds />
<MessagePrimitive.Error>
<ErrorPrimitive.Root
className="mt-1.5 flex flex-col gap-1.5 rounded-lg border border-[color-mix(in_srgb,var(--dt-destructive)_35%,transparent)] bg-[color-mix(in_srgb,var(--dt-destructive)_7%,transparent)] px-3 py-2 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]"
role="alert"
>
<div className="flex items-start gap-1.5">
<div className="min-w-0 flex-1">
<ErrorLayerLabel />
<ErrorPrimitive.Message className="min-w-0" />
</div>
{onDismissError && (
<TooltipIconButton
className="-my-0.5 shrink-0 text-current opacity-70 hover:opacity-100"
onClick={() => onDismissError(messageId)}
side="top"
tooltip={t.assistant.thread.dismissError}
>
<XIcon className="size-3.5" />
</TooltipIconButton>
)}
</div>
<ErrorRecoveryActions />
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
</div>
<MessageTimelineTimestamp className="px-(--message-text-indent) pt-0.5" suppressIfDuplicatePart />
{hasVisibleText && !isInterim && (
<AssistantFooter
durationS={turnDurationS}
getMessageText={getMessageText}
messageId={messageId}
onBranchInNewChat={onBranchInNewChat}
/>
)}
{/* Last thing in the turn — under the action bar, the way Cursor ends a
turn on its summary rather than burying it above the controls. */}
<SettledChangedFiles />
<StreamingMarker />
</>
)}
</MessagePrimitive.Root>
)
}
/**
* PERF leaf: the only subscriber to this message's streaming status inside the
* message content. Previously `messageStatus` / `isPlaceholder` /
* `isLastMessage` were read by AssistantMessage itself, so every pending flip
* re-rendered the whole message subtree — at stream breadth N, N subtrees in a
* single commit, which is what widened the recalc scope. Reading them here
* confines the flip to this leaf; the sibling parts subtree is a hoisted
* constant element and bails out.
*
* Behaviour is byte-identical to the old inline expression, including the
* TAIL-ONLY rule: the activity row belongs to the tail of the thread, period.
* A stale pending bubble mid-transcript (a turn that ended without its settle
* event, a steer race) must never show one — a spinner above a later user
* message reads as the agent answering out of order.
*
* The activity row is mounted by the TAIL of the thread and decides for itself
* whether the turn owes the user a line, so there is deliberately no
* `isRunning` gate on the mount here. Gating it on this bubble's own `running`
* status was the hole: a turn that seals a bubble mid-flight (message.interim)
* or finishes one while the agent keeps going leaves a settled message at the
* tail, so the row unmounted and the seconds went uncounted while the
* composer's arc border and Stop button said work was still happening.
* TurnActivityIndicator subscribes to the status it needs internally, so it is
* itself a leaf and this stays off the message root either way.
*/
const AssistantStatusSlot: FC = () => {
// ONE subscription, not one per input. Each useAuiState is a separate store
// subscription with its own equality check and its own chance to schedule a
// render, and these inputs always move together on a status flip — so
// reading them separately just multiplies the wake-ups for a single logical
// change. The selector collapses them to one stable string, which bails out
// on every flush that does not actually change what this slot renders.
const slot = useAuiState(s => {
if (s.thread.messages[s.thread.messages.length - 1]?.id !== s.message.id) {
return 'none'
}
return s.message.status?.type === 'running' && s.message.content.length === 0 ? 'placeholder' : 'activity'
})
if (slot === 'none') {
return null
}
return slot === 'placeholder' ? <ResponseLoadingIndicator /> : <TurnActivityIndicator />
}
/**
* PERF leaf: owns the settled-text selector that feeds the link previews.
*
* This was the last status-dependent read at the message root, and the most
* expensive one: the selector flips between '' while running and the full
* `messageContentText(content)` join once settled, so every running <-> settled
* transition re-ran the join for the whole message AND re-rendered the root.
* At stream breadth N that is N joins plus N root re-renders per flip. Reading
* it here confines both to this leaf, which renders nothing at all in the
* common case.
*
* The streaming-side optimization is unchanged and still the point of the ''
* branch: preview targets only materialize once the turn completes, so while
* running the selector returns a stable '' and per-token flushes skip the
* regex scan and the re-render it would cause.
*
* Renders exactly what the root used to render at this position — the same
* wrapper div with the same classes, or nothing when there are no targets —
* so the DOM is byte-identical either way. A component boundary adds no node
* of its own, so unlike StreamingMarker this needs no placement care.
*/
const AssistantPreviewEmbeds: FC = () => {
const completedText = useAuiState(s =>
s.message.status?.type === 'running' ? '' : messageContentText(s.message.content)
)
const previewTargets = useMemo(() => {
if (!completedText || !/(https?:\/\/|file:\/\/)/i.test(completedText)) {
return []
}
return pickPrimaryPreviewTarget(extractPreviewTargets(completedText))
}, [completedText])
if (previewTargets.length === 0) {
return null
}
return (
<div className="mt-3 flex flex-wrap gap-2">
{previewTargets.map(target => (
<PreviewAttachment key={target} source="explicit-link" target={target} />
))}
</div>
)
}
/**
* PERF leaf: owns the `settledParts` selector so the tail's settle stops
* re-rendering the message root. This is the one status-derived selector that
* returns an OBJECT (`s.message.parts`) rather than a primitive, so it cannot
* bail out on identity churn — keeping it at the root meant every settle
* re-rendered the root and everything under it.
*
* Cursor's changed-files card only appears once the turn settles: while the
* agent is still editing, the tool rows narrate each patch and a card that
* grew a row per write would thrash the transcript. `EMPTY_PARTS` while
* running keeps this selector referentially stable across the 30 Hz delta
* stream.
*
* It also only rides the LAST turn. The card is a "here's what just landed"
* summary, not a per-turn artifact: leaving one behind on every reply would
* stack a wall of stale cards down the transcript. Sending the next message
* retires it — the working tree it describes is already history by then.
*/
const SettledChangedFiles: FC = () => {
const settledParts = useAuiState(s => {
const isLastMessage = s.thread.messages[s.thread.messages.length - 1]?.id === s.message.id
return s.message.status?.type === 'running' || !isLastMessage ? EMPTY_PARTS : s.message.parts
})
return <ChangedFilesCard parts={settledParts} />
}
/**
* Carries the streaming flag that used to sit on the message root as
* `data-streaming`.
*
* The flag has no CSS behind it (every `[data-streaming='true']` rule targets
* `[data-slot='code-card']`), but it is not dead: it is the settled-row signal
* for the short-session hang repro, which derives the settled count by
* subtracting the number of `[data-message-streaming='true']` markers from the
* number of message roots, and gates the assistant-response wait on that count
* growing. At most one marker per row carries the attribute, which is what
* makes the subtraction exact.
*
* Deliberately NOT named `data-streaming`: shiki-highlighter.tsx puts that
* exact attribute on a deferred `[data-slot='code-card']`, which is a
* descendant of this root. Once the repro matches on a descendant rather than
* the root's own attribute, a shared name would make any message holding a
* still-deferred code card read as "still streaming". A distinct name keeps
* the signal about the MESSAGE and immune to how deep it sits.
*
* On the root it was a per-flip attribute write on the element that owns the
* whole message subtree, which is the invalidation this prong exists to remove.
* Three properties make this placement cheap and behaviour-neutral:
*
* - A ROOT-LEVEL sibling, not a child of the message content. The
* `:first-child` / `:last-child` margin rules in styles.css match blocks
* *inside* `[data-slot='aui_assistant-message-content']`; a node added
* there would steal `:last-child` from the status indicator and silently
* change the gap between bubbles mid-stream. No rule selects message-root
* children by position, so this slot is inert.
* - PERMANENTLY MOUNTED, toggling only the attribute. Mounting/unmounting per
* flip would be a DOM structure change and dirty its siblings; an attribute
* write on a childless node invalidates exactly one element.
* - `display: none`, so it costs no layout or paint. `querySelectorAll` and
* `:has()` still match it — they read the DOM, not the box tree.
*
* Tracks plain `isRunning` (not tail-only), exactly like the old root
* attribute, so the repro's row accounting is unchanged.
*/
const StreamingMarker: FC = () => {
const isRunning = useAuiState(s => s.message.status?.type === 'running')
return (
<span
aria-hidden="true"
className="hidden"
data-message-streaming={isRunning ? 'true' : undefined}
data-slot="aui_message-streaming-marker"
/>
)
}
// ── Layered error card pieces ────────────────────────────────────────────
//
// The gateway stamps failed turns with a structured {layer, code, retryable}
// descriptor (metadata.custom.errorSurface — see agent/error_surface.py).
// These leaves render the layer label + recovery actions. Older backends
// never send the descriptor: the label falls back to a generic title and the
// action row still offers Retry / Open Logs / Copy error details, so nothing
// regresses on version skew.
const ErrorLayerLabel: FC = () => {
const { t } = useI18n()
const surface = useAuiState(s => s.message.metadata?.custom?.errorSurface as ErrorSurface | undefined)
const labels = t.assistant.thread.errorLayers
const label = (surface && labels[surface.layer]) || labels.generic
return <div className="font-medium">{label}</div>
}
// Isolated because useNavigate() THROWS outside a <Router> (bare test
// harnesses, embedded panes render threads router-free). The parent gates
// this child's mount on useInRouterContext(), which is safe anywhere.
const SwitchProviderAction: FC<{ label: string }> = ({ label }) => {
const navigate = useNavigate()
return (
<button className="aui-error-action" onClick={() => navigate(`${SETTINGS_ROUTE}?tab=config:model`)} type="button">
{label}
</button>
)
}
const ErrorRecoveryActions: FC = () => {
const { t } = useI18n()
const copy = t.assistant.thread
const surface = useAuiState(s => s.message.metadata?.custom?.errorSurface as ErrorSurface | undefined)
const errorText = useAuiState(s => {
const status = s.message.status as { error?: unknown; type?: string } | undefined
return status?.type === 'incomplete' && typeof status.error === 'string' ? status.error : ''
})
// useNavigate() would throw here when no Router is above us; the deep-link
// child mounts only when one is (see SwitchProviderAction).
const inRouter = useInRouterContext()
const model = useStore($currentModel)
const connection = useStore($connection)
// Open Logs reveals the LOCAL Electron profile's HERMES_HOME/logs. On a
// remote/cloud connection the failed turn's gateway+agent logs live on the
// remote box — the local folder only holds Desktop-side transport logs, so
// the label says "Open Desktop logs" there instead of implying it opens the
// runtime's logs.
const remoteConnection = connection?.mode === 'remote'
// Retry = assistant-ui reload (same wiring as the footer's refresh action):
// re-runs the failed turn's prompt in place. Suppressed when the classifier
// says the failure is deterministic (retrying reproduces it).
const retryable = !surface || surface.retryable
// Switch Provider deep-links Settings → Models for the layers where the fix
// is provider/endpoint/auth config, not a retry.
const showSwitchProvider = surface != null && ['auth', 'billing', 'endpoint', 'provider'].includes(surface.layer)
const openLogs = useCallback(async () => {
try {
const root = await window.hermesDesktop?.logsRoot?.()
if (!root) {
notifyError(new Error('logs root unavailable'), copy.errorOpenLogsFailed)
return
}
const result = await window.hermesDesktop?.openDir?.(root)
if (result && !result.ok) {
notifyError(new Error(result.error || 'open failed'), copy.errorOpenLogsFailed)
}
} catch (error) {
notifyError(error, copy.errorOpenLogsFailed)
}
}, [copy.errorOpenLogsFailed])
const diagnosticsText = useCallback(
() =>
formatErrorDiagnostics({
errorText,
model: model || undefined,
surface
}),
[errorText, model, surface]
)
return (
<div className="flex flex-wrap items-center gap-1.5">
{retryable && (
<ActionBarPrimitive.Reload asChild>
<button className="aui-error-action" onClick={() => triggerHaptic('submit')} type="button">
<RefreshCwIcon className="size-3" />
{copy.errorRetry}
</button>
</ActionBarPrimitive.Reload>
)}
{showSwitchProvider && inRouter && <SwitchProviderAction label={copy.errorSwitchProvider} />}
{window.hermesDesktop?.logsRoot && (
<button className="aui-error-action" onClick={() => void openLogs()} type="button">
{remoteConnection ? copy.errorOpenDesktopLogs : copy.errorOpenLogs}
</button>
)}
<button className="aui-error-action" onClick={() => requestSendDiagnostics(diagnosticsText())} type="button">
<Upload className="size-3" />
{copy.errorSendDiagnostics}
</button>
<CopyButton
appearance="inline"
className="aui-error-action"
label={copy.errorCopyDiagnostics}
text={diagnosticsText}
/>
</div>
)
}
const AssistantActionBar: FC<MessageActionProps & { durationS?: number }> = ({
durationS,
messageId,
getMessageText,
onBranchInNewChat
}) => {
const { t } = useI18n()
const copy = t.assistant.thread
const [pickerOpen, setPickerOpen] = useState(false)
const { enabled: reactionsEnabled, react, reactions: shownReactions } = useMessageReactions(messageId, 'assistant')
const pickEmoji = useCallback(
(emoji: null | string) => {
setPickerOpen(false)
react(emoji)
},
[react]
)
return (
<div className="relative flex w-full shrink-0 items-center justify-end gap-1.5">
{durationS !== undefined && (
<span
className="mr-auto select-none px-0.5 text-[0.6875rem] leading-5 tabular-nums text-muted-foreground"
data-slot="aui_turn-duration"
title={t.assistant.thread.turnDuration(formatElapsed(durationS))}
>
{formatElapsed(durationS)}
</span>
)}
<ActionBarPrimitive.Root
className={
// NOTE: intentionally NOT `hideWhenRunning`. That prop unmounts the
// bar while the thread streams, which collapses every completed
// assistant message's footer by this bar's height and shifts the
// whole conversation when the turn resolves. The bar is already
// invisible by default (opacity-0 + pointer-events-none, reveals on
// hover), so keeping it mounted reserves stable layout height with
// no visual change during streaming.
'relative flex flex-row items-center justify-end gap-1.5 py-1.5 opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100'
}
data-slot="aui_msg-actions"
>
{onBranchInNewChat && (
<TooltipIconButton
onClick={() => {
triggerHaptic('selection')
onBranchInNewChat(messageId)
}}
tooltip={copy.branchNewChat}
>
<GitForkIcon className="size-3.5" />
</TooltipIconButton>
)}
<CopyButton appearance="icon" buttonSize="icon" label={copy.copy} text={getMessageText} />
<ReadAloudButton getText={getMessageText} messageId={messageId} />
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip={copy.refresh}>
<RefreshCwIcon className="size-3.5" />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
</ActionBarPrimitive.Root>
{/* ONE slot, Slack-style: the picker trigger and the landed reaction are
the same element, so reacting never shifts layout. Empty → ☺, hidden
until hover like its action-bar neighbors (state lives in styles.css
— the aui_msg-reactions rules outweigh Tailwind utilities here).
Reacted → the emoji itself, always visible at full strength, and
clicking it reopens the picker to switch or retract. Outside
ActionBarPrimitive.Root so a landed reaction doesn't ride the bar's
hover opacity. */}
{(reactionsEnabled || shownReactions.length > 0) && (
<ReactionPicker
onOpenChange={setPickerOpen}
onSelect={pickEmoji}
open={pickerOpen}
selected={shownReactions.find(reaction => reaction.author === 'user')?.emoji}
>
<TooltipIconButton
data-reacted={shownReactions.length > 0 || undefined}
data-slot="aui_msg-reactions"
data-state={pickerOpen ? 'open' : undefined}
onClick={reactionsEnabled ? () => setPickerOpen(open => !open) : undefined}
tooltip={copy.react}
>
{shownReactions.length > 0 ? (
<span className="flex items-center gap-0.5 text-[0.8125rem] leading-none">
{shownReactions.map(reaction => (
<span className="reaction-pop" key={`${reaction.author}-${reaction.emoji}`}>
{reaction.emoji}
</span>
))}
</span>
) : (
<SmilePlusIcon className="size-3.5" />
)}
</TooltipIconButton>
</ReactionPicker>
)}
</div>
)
}
const ReadAloudButton: FC<{ getText: () => string; messageId: string }> = ({ getText, messageId }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const voicePlayback = useStore($voicePlayback)
const view = useSessionView()
const sessionId = useStore(view.$runtimeId)
const readAloudStatus =
voicePlayback.source === 'read-aloud' && voicePlayback.messageId === messageId ? voicePlayback.status : 'idle'
const isPreparing = readAloudStatus === 'preparing'
const isSpeaking = readAloudStatus === 'speaking'
const anyPlaybackActive = voicePlayback.status !== 'idle'
const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : AudioLines
const tooltip = isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud
const read = useCallback(async () => {
const text = getText()
if (!text || $voicePlayback.get().status !== 'idle') {
return
}
try {
await playSpeechText(text, { messageId, source: 'read-aloud' })
markAssistantIdSpoken(sessionId, view.$messages.get(), messageId)
} catch (error) {
notifyError(error, copy.readAloudFailed)
}
}, [copy.readAloudFailed, getText, messageId, sessionId, view.$messages])
return (
<TooltipIconButton
disabled={isPreparing || (!isSpeaking && anyPlaybackActive)}
onClick={() => {
triggerHaptic('selection')
void (isSpeaking ? stopVoicePlayback() : read())
}}
tooltip={tooltip}
>
<Icon className={cn('size-3.5', isPreparing && 'animate-spin')} />
</TooltipIconButton>
)
}
const AssistantFooter: FC<MessageActionProps & { durationS?: number }> = ({ durationS, ...props }) => {
return (
<div className="flex min-h-6 flex-col items-end gap-1 pr-(--message-text-indent) pl-(--message-text-indent)">
<BranchPickerPrimitive.Root
className="inline-flex h-6 items-center gap-1 text-xs text-muted-foreground"
hideWhenSingleBranch
>
<BranchPickerPrimitive.Previous className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
<Codicon name="chevron-left" size="0.875rem" />
</BranchPickerPrimitive.Previous>
<span className="tabular-nums">
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
<Codicon name="chevron-right" size="0.875rem" />
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
<AssistantActionBar durationS={durationS} {...props} />
</div>
)
}
@@ -0,0 +1,102 @@
// Lists and blockquotes have chrome beside the text (markers, the quote
// border) whose side is driven by the box's CSS direction, which the
// unicode-bidi:plaintext rules never touch. These tests pin the split of
// responsibilities: ul/ol/blockquote carry dir="auto" so the browser
// resolves their box direction from content, inline code carries dir="ltr"
// so it neither votes in that resolution nor reorders, and plain prose
// blocks stay attribute-free (the plaintext CSS owns them). jsdom does not
// resolve dir="auto", so the contract is asserted at the attribute level.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { stubThreadEnvironment, stubThreadViewportSize } from '../test-utils'
import { Thread } from '.'
const createdAt = new Date('2026-06-01T00:00:00.000Z')
stubThreadEnvironment()
stubThreadViewportSize()
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'hi' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistantMessage(text: string): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function Harness({ text }: { text: string }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistantMessage(text)],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
describe('block-level direction chrome', () => {
it('lists carry dir="auto" so markers follow the resolved direction', async () => {
render(<Harness text={'מקומות:\n\n1. חוף גורדון\n2. שוק הכרמל\n\n- פריט\n- item'} />)
const item = await screen.findByText(/חוף גורדון/)
expect(item.closest('ol')?.getAttribute('dir')).toBe('auto')
const bullet = await screen.findByText(/פריט/)
expect(bullet.closest('ul')?.getAttribute('dir')).toBe('auto')
})
it('blockquotes carry dir="auto" so the border follows the resolved direction', async () => {
render(<Harness text={'> ציטוט קצר בעברית'} />)
const quote = await screen.findByText(/ציטוט קצר/)
expect(quote.closest('blockquote')?.getAttribute('dir')).toBe('auto')
})
it('inline code carries dir="ltr" so it does not vote in dir="auto" resolution', async () => {
render(<Harness text={'1. `npm install` מתקין תלויות'} />)
const code = await screen.findByText('npm install')
expect(code.tagName).toBe('CODE')
expect(code.getAttribute('dir')).toBe('ltr')
expect(code.closest('ol')?.getAttribute('dir')).toBe('auto')
})
it('plain prose blocks stay attribute-free (plaintext CSS owns them)', async () => {
render(<Harness text={'שלום לכולם'} />)
const paragraph = await screen.findByText(/שלום לכולם/)
expect(paragraph.closest('p')?.hasAttribute('dir')).toBe(false)
})
})
@@ -0,0 +1,75 @@
import { useStore } from '@nanostores/react'
import { type FC, useMemo } from 'react'
import { useComposerScope } from '@/app/chat/composer/scope'
import { useSessionView } from '@/app/chat/session-view'
import { deriveChangedFiles } from '@/components/assistant-ui/thread/changed-files'
import { WIDGET_SHELL_CLASS } from '@/components/chat/widget-shell'
import { DiffCount } from '@/components/ui/diff-count'
import { FadeScroll } from '@/components/ui/fade-scroll'
import { FileTypeIcon } from '@/components/ui/file-type-icon'
import { useI18n } from '@/i18n'
import { displayPath } from '@/lib/display-path'
import { cn } from '@/lib/utils'
import { openReviewForPath, revealReview } from '@/store/review'
// ~5 rows. A turn that rewrites twenty files should still read as one card in
// the transcript, not a wall the user has to scroll past to reach the composer.
const MAX_ROWS_HEIGHT = '9.375rem'
/**
* Cursor-style "N files changed" summary closing out the newest assistant turn:
* one row per file it edited with that file's +/-, and a Review action opening
* the diff pane (⌘G). A row click opens that file's diff directly.
*
* Wears the shared `WIDGET_SHELL_CLASS` so it reads as the same panel as the
* transcript's other inline widgets rather than inventing its own chrome.
*/
export const ChangedFilesCard: FC<{ parts: readonly unknown[] }> = ({ parts }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const files = useMemo(() => deriveChangedFiles(parts), [parts])
// Review THIS surface's repo: a tile transcript pins the pane to the tile's
// worktree; the primary passes null (follow the active session, as before).
const view = useSessionView()
const viewCwd = useStore(view.$cwd)
const scopeCwd = view.kind === 'primary' ? null : viewCwd || null
const composerScope = useComposerScope()
if (files.length === 0) {
return null
}
return (
<div
className={cn(WIDGET_SHELL_CLASS, 'mt-1.5 text-[length:var(--conversation-tool-font-size)]')}
data-slot="aui_changed-files"
>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-(--ui-text-primary)">{copy.filesChanged(files.length)}</span>
<button
className="shrink-0 cursor-pointer text-(--ui-text-tertiary) transition-colors hover:text-(--ui-text-primary)"
onClick={() => revealReview(scopeCwd, composerScope.target)}
type="button"
>
{copy.reviewChanges}
</button>
</div>
<FadeScroll className="-mx-1.5 mt-1.5 flex flex-col px-1.5" maxHeight={MAX_ROWS_HEIGHT}>
{files.map(file => (
<button
className="row-hover flex shrink-0 items-center gap-2 rounded-md px-1.5 py-1 text-left"
key={file.path}
onClick={() => void openReviewForPath(file.path, scopeCwd, composerScope.target)}
title={displayPath(file.path)}
type="button"
>
<FileTypeIcon className="shrink-0 text-(--ui-text-tertiary)" path={file.path} size="0.875rem" />
<span className="min-w-0 flex-1 truncate text-(--ui-text-secondary)">{file.name}</span>
<DiffCount added={file.added} removed={file.removed} />
</button>
))}
</FadeScroll>
</div>
)
}
@@ -0,0 +1,69 @@
// Pure derivation for the assistant message's "N files changed" card: fold a
// turn's file-edit tool parts into one row per file. No React/DOM.
import {
countDiffLineStats,
fileEditBasename,
fileEditPath,
inlineDiffFromResult,
isFileEditTool,
parseMaybeObject
} from '@/components/assistant-ui/tool/fallback-model'
export interface ChangedFile {
added: number
/** Basename, for the row label. */
name: string
/** Path exactly as the tool reported it (absolute or repo-relative). */
path: string
removed: number
}
interface ChangedFilePart {
args?: unknown
result?: unknown
toolName?: unknown
type?: unknown
}
/**
* One row per file the turn edited, in first-touched order, with the +/- of
* every edit to that file summed. Only landed edits with a diff count: a call
* still running has no result, and a failed one changed nothing.
*/
export function deriveChangedFiles(parts: readonly unknown[]): ChangedFile[] {
const byPath = new Map<string, ChangedFile>()
for (const raw of parts) {
const part = (raw ?? {}) as ChangedFilePart
if (part.type !== 'tool-call' || typeof part.toolName !== 'string' || !isFileEditTool(part.toolName)) {
continue
}
const result = parseMaybeObject(part.result)
const diff = inlineDiffFromResult(result)
if (!diff) {
continue
}
const path = fileEditPath(parseMaybeObject(part.args), result)
if (!path) {
continue
}
const stats = countDiffLineStats(diff)
const existing = byPath.get(path)
if (existing) {
existing.added += stats.added
existing.removed += stats.removed
} else {
byPath.set(path, { added: stats.added, name: fileEditBasename(path), path, removed: stats.removed })
}
}
return [...byPath.values()]
}
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import {
contentHasVisibleText,
messageAttachmentRefs,
messageContentText,
partText,
pickPrimaryPreviewTarget
} from './content'
describe('partText', () => {
it('returns plain strings as-is', () => {
expect(partText('hello')).toBe('hello')
})
it('reads text from untyped and text parts', () => {
expect(partText({ text: 'a' })).toBe('a')
expect(partText({ type: 'text', text: 'b' })).toBe('b')
})
it('ignores non-text parts and malformed input', () => {
expect(partText({ type: 'tool', text: 'x' })).toBe('')
expect(partText({ text: 42 })).toBe('')
expect(partText(null)).toBe('')
expect(partText(undefined)).toBe('')
})
})
describe('messageContentText', () => {
it('trims string content', () => {
expect(messageContentText(' hi ')).toBe('hi')
})
it('concatenates array text parts and trims', () => {
expect(messageContentText([{ text: ' a' }, { type: 'text', text: 'b ' }])).toBe('ab')
})
it('returns empty string for non-string, non-array content', () => {
expect(messageContentText(null)).toBe('')
expect(messageContentText({ text: 'x' })).toBe('')
})
})
describe('contentHasVisibleText', () => {
it('detects visible text in strings and arrays', () => {
expect(contentHasVisibleText('hi')).toBe(true)
expect(contentHasVisibleText([{ text: ' ' }, { text: 'x' }])).toBe(true)
})
it('is false when there is no visible text', () => {
expect(contentHasVisibleText(' ')).toBe(false)
expect(contentHasVisibleText([{ text: ' ' }, { type: 'tool', text: 'y' }])).toBe(false)
expect(contentHasVisibleText(null)).toBe(false)
})
})
describe('messageAttachmentRefs', () => {
it('returns string arrays untouched', () => {
const value = ['@file:a', '@file:b']
expect(messageAttachmentRefs(value)).toBe(value)
})
it('returns a stable empty array for invalid input', () => {
const a = messageAttachmentRefs(null)
const b = messageAttachmentRefs([1, 2])
expect(a).toEqual([])
expect(a).toBe(b)
})
})
describe('pickPrimaryPreviewTarget', () => {
it('returns the input when one or zero targets', () => {
expect(pickPrimaryPreviewTarget([])).toEqual([])
expect(pickPrimaryPreviewTarget(['https://x.dev'])).toEqual(['https://x.dev'])
})
it('prefers a localhost URL when present', () => {
expect(pickPrimaryPreviewTarget(['https://example.com', 'http://localhost:3000'])).toEqual([
'http://localhost:3000'
])
})
it('falls back to the last target when no localhost URL', () => {
expect(pickPrimaryPreviewTarget(['https://a.dev', 'https://b.dev'])).toEqual(['https://b.dev'])
})
})
@@ -0,0 +1,63 @@
const EMPTY_ATTACHMENT_REFS: string[] = []
export function partText(part: unknown): string {
if (typeof part === 'string') {
return part
}
if (!part || typeof part !== 'object') {
return ''
}
const row = part as { text?: unknown; type?: unknown }
return (!row.type || row.type === 'text') && typeof row.text === 'string' ? row.text : ''
}
export function messageContentText(content: unknown): string {
if (typeof content === 'string') {
return content.trim()
}
return Array.isArray(content) ? content.map(partText).join('').trim() : ''
}
// Cheap streaming-stable "does this message have visible text" check: returns
// on the first non-whitespace text part without concatenating the whole
// message. Used as a useAuiState selector so its boolean output stays stable
// across token flushes (flips false→true once per turn).
export function contentHasVisibleText(content: unknown): boolean {
if (typeof content === 'string') {
return content.trim().length > 0
}
if (!Array.isArray(content)) {
return false
}
for (const part of content) {
if (partText(part).trim().length > 0) {
return true
}
}
return false
}
export function messageAttachmentRefs(value: unknown): string[] {
if (!Array.isArray(value)) {
return EMPTY_ATTACHMENT_REFS
}
return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS
}
export function pickPrimaryPreviewTarget(targets: string[]): string[] {
if (targets.length <= 1) {
return targets
}
const localUrl = targets.find(value => /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(value))
return [localUrl || targets[targets.length - 1]]
}
@@ -0,0 +1,94 @@
// Double-click an assistant reply to heart it (the iMessage gesture), gated on
// the same opt-in toggle as the rest of message reactions.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ReactionsStore from '@/store/reactions'
import { $reactionsEnabled } from '@/store/reactions-enabled'
import { $localReactions } from '@/store/reactions-local'
import { assistantMessage, stubThreadEnvironment } from '../test-utils'
import { isTapbackDoubleClick } from './use-message-reactions'
import { Thread } from '.'
stubThreadEnvironment()
// The gesture persists through the gateway; this suite is about the local
// paint, which is what the user actually sees on the click.
vi.mock('@/store/reactions', async importOriginal => ({
...(await importOriginal<typeof ReactionsStore>()),
toggleMessageReaction: vi.fn(async () => {})
}))
function Harness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantMessage()],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
beforeEach(() => {
$localReactions.set({})
$reactionsEnabled.set(false)
})
afterEach(() => {
cleanup()
})
describe('isTapbackDoubleClick', () => {
it('claims a plain double-click on message body', () => {
expect(isTapbackDoubleClick({ detail: 2, target: document.createElement('span') })).toBe(true)
})
it('ignores a triple-click, so selecting a paragraph does not re-toggle', () => {
expect(isTapbackDoubleClick({ detail: 3, target: document.createElement('span') })).toBe(false)
})
it('leaves double-click alone where it already means something', () => {
const code = document.createElement('pre')
const inner = document.createElement('code')
code.append(inner)
expect(isTapbackDoubleClick({ detail: 2, target: inner })).toBe(false)
expect(isTapbackDoubleClick({ detail: 2, target: document.createElement('a') })).toBe(false)
expect(isTapbackDoubleClick({ detail: 2, target: document.createElement('button') })).toBe(false)
})
})
describe('double-click to heart an assistant message', () => {
it('hearts the message, and a second double-click retracts it', async () => {
$reactionsEnabled.set(true)
render(<Harness />)
const message = (await screen.findByText('done')).closest('[data-slot="aui_assistant-message-root"]')
expect(message).toBeTruthy()
fireEvent.doubleClick(message!, { detail: 2 })
await waitFor(() => expect($localReactions.get()['assistant-1']?.[0]?.emoji).toBe('❤️'))
fireEvent.doubleClick(message!, { detail: 2 })
await waitFor(() => expect($localReactions.get()['assistant-1']).toEqual([]))
})
it('does nothing while reactions are off', async () => {
render(<Harness />)
const message = (await screen.findByText('done')).closest('[data-slot="aui_assistant-message-root"]')
fireEvent.doubleClick(message!, { detail: 2 })
expect($localReactions.get()['assistant-1']).toBeUndefined()
})
})
@@ -0,0 +1,215 @@
// Loading and activity indicators mount only on the thread's last message.
// The tail-only gate from ba756333 keeps non-tail running bubbles silent,
// including assistants followed only by a user or system row. The optimistic
// placeholder flow renders exactly one status row. These contracts pin the
// duplicate-indicator regression tracked in #68634.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { __resetElapsedTimerRegistryForTests } from '@/components/chat/activity-timer'
import { setSessionCompacting } from '@/store/compaction'
import { $activeSessionId, $turnStartedAt } from '@/store/session'
import { stubThreadEnvironment, stubThreadViewportSize, userMessage } from '../test-utils'
import { Thread } from '.'
// Layout/observer stubs mirrored from streaming.test.tsx. jsdom has no
// ResizeObserver, rAF, or real layout, and the Thread scroll container needs
// non-zero dimensions to mount without throwing.
stubThreadEnvironment()
stubThreadViewportSize()
const createdAt = new Date('2026-05-01T00:00:00.000Z')
const sessionId = 'session-68634'
// This shape mirrors the `/steer` note appended by appendSessionTextMessage
// in apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts.
function systemMessage(id: string, text: string): ThreadMessage {
return {
id,
role: 'system',
content: [{ type: 'text', text }],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function runningAssistantMessage(id: string, text: string): ThreadMessage {
return {
id,
role: 'assistant',
content: [{ type: 'text', text }],
status: { type: 'running' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function Harness({ messages, isRunning = false }: { messages: ThreadMessage[]; isRunning?: boolean }) {
// isRunning: false at the runtime level. Per-message `status: {type:
// 'running'}` is what drives TurnActivityIndicator mounting.
// Passing isRunning: true makes useExternalStoreRuntime auto-append a
// synthetic empty trailing assistant placeholder whenever the last message
// is not already a running assistant, such as the trailing user prompt
// cases below. That is the real production flow. The isRunning:true tests
// prove that the placeholder is treated as the tail and renders its own
// loading row while the real bubble's activity row stays silent.
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages,
isRunning,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
describe('TurnActivityIndicator tail gating (#68634)', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
__resetElapsedTimerRegistryForTests()
$activeSessionId.set(sessionId)
$turnStartedAt.set(Date.now())
setSessionCompacting(sessionId, true)
})
afterEach(() => {
cleanup()
setSessionCompacting(sessionId, false)
$activeSessionId.set(null)
$turnStartedAt.set(null)
__resetElapsedTimerRegistryForTests()
vi.useRealTimers()
})
it('renders exactly one indicator, on the later bubble, when two assistant bubbles are running with content', () => {
const { container } = render(
<Harness
messages={[
userMessage('user-1', 'Summarize this thread for me'),
runningAssistantMessage('assistant-1', 'Working on it'),
userMessage('user-2', 'hola?'),
runningAssistantMessage('assistant-2', 'On it too')
]}
/>
)
act(() => {
vi.advanceTimersByTime(5_000)
})
const indicators = screen.getAllByRole('status', { name: 'Summarizing thread' })
expect(indicators.length).toBe(1)
const roots = container.querySelectorAll('[data-slot="aui_assistant-message-root"]')
expect(roots.length).toBe(2)
// The second assistant root is also the thread's any-role tail.
expect(roots[0]?.querySelector('[data-slot="aui_turn-activity"]')).toBeNull()
expect(roots[1]?.querySelector('[data-slot="aui_turn-activity"]')).not.toBeNull()
})
it('keeps a running assistant silent when a queued user prompt trails it and the runtime is idle', () => {
const { container } = render(
<Harness
messages={[
userMessage('user-1', 'Summarize this thread for me'),
runningAssistantMessage('assistant-1', 'Working on it'),
userMessage('user-2', 'hola?')
]}
/>
)
act(() => {
vi.advanceTimersByTime(5_000)
})
expect(container.querySelector('[data-slot="aui_response-loading"]')).toBeNull()
expect(container.querySelector('[data-slot="aui_turn-activity"]')).toBeNull()
})
it('keeps a running assistant silent when a steer system note trails it and the runtime is idle', () => {
const { container } = render(
<Harness
messages={[
userMessage('user-1', 'Summarize this thread for me'),
runningAssistantMessage('assistant-1', 'Working on it'),
systemMessage('system-steer-1', 'steer:focus on the errors')
]}
/>
)
act(() => {
vi.advanceTimersByTime(5_000)
})
expect(container.querySelector('[data-slot="aui_response-loading"]')).toBeNull()
expect(container.querySelector('[data-slot="aui_turn-activity"]')).toBeNull()
})
// In the production flow, isRunning: true with a trailing queued user prompt
// makes the runtime append an empty optimistic assistant placeholder after
// the real running bubble. The placeholder renders ResponseLoadingIndicator.
// During compaction, that row carries the same accessible label as the stall
// indicator. The real non-tail bubble must remain silent so there is exactly
// one status row.
it('still renders the indicator when the runtime appends an optimistic placeholder (isRunning:true)', () => {
render(
<Harness
isRunning
messages={[
userMessage('user-1', 'Summarize this thread for me'),
runningAssistantMessage('assistant-1', 'Working on it'),
userMessage('user-2', 'hola?')
]}
/>
)
act(() => {
vi.advanceTimersByTime(5_000)
})
const indicators = screen.getAllByRole('status', { name: 'Summarizing thread' })
expect(indicators.length).toBe(1)
// The surviving row belongs to the placeholder, while the real running
// bubble's stall row stays silent.
expect(document.querySelectorAll('[data-slot="aui_response-loading"]').length).toBe(1)
expect(document.querySelectorAll('[data-slot="aui_turn-activity"]').length).toBe(0)
})
// Outside compaction, the placeholder uses the plain loading label and the
// real bubble's stall row must remain silent after the stall threshold.
it('keeps a single status row for the placeholder outside compaction (isRunning:true)', () => {
setSessionCompacting(sessionId, false)
render(
<Harness
isRunning
messages={[
userMessage('user-1', 'Summarize this thread for me'),
runningAssistantMessage('assistant-1', 'Working on it'),
userMessage('user-2', 'hola?')
]}
/>
)
act(() => {
vi.advanceTimersByTime(5_000)
})
expect(document.querySelectorAll('[data-slot="aui_response-loading"]').length).toBe(1)
expect(document.querySelectorAll('[data-slot="aui_turn-activity"]').length).toBe(0)
})
})
@@ -0,0 +1,130 @@
// Thread deliberately keeps cwd/gateway/sessionId OUT of the messageComponents
// memo deps: those values change on every session switch, and reminting the
// component types mid-switch remounts the whole outgoing transcript. The
// mounted edit composer still has to see a same-session change (e.g. a cwd
// remap). It used to read the values from a render-time ref, but a mounted
// composer never re-reads the ref when the change leaves every
// ThreadMessageList prop referentially equal (Thread and ThreadMessageList
// are both memo'd, so the wrapper never re-renders). The values now travel
// through ThreadEditContext, whose propagation reaches the mounted consumer
// through the memo bail-out. These tests pin both directions: the composer
// sees the change, and the transcript still does not remount.
import { ExportedMessageRepository } from '@assistant-ui/react'
import { AssistantRuntimeProvider, type ThreadMessage } from '@assistant-ui/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { assistantMessage, stubThreadEnvironment, stubThreadViewportSize, userMessage } from '../test-utils'
import { Thread } from '.'
interface MockComposerProps {
cwd: string | null
gateway: unknown
sessionId: string | null
}
const composerRenders = vi.hoisted(() => [] as MockComposerProps[])
vi.mock('./user-edit-composer', () => ({
UserEditComposer: (props: MockComposerProps) => {
composerRenders.push(props)
return <div data-testid="edit-composer">{props.cwd}</div>
}
}))
stubThreadEnvironment()
afterEach(() => {
cleanup()
})
beforeEach(() => {
composerRenders.length = 0
})
stubThreadViewportSize()
const noopAsync = async () => {}
// The repository must stay referentially stable across rerenders: a new
// object would make the incremental runtime resync the transcript and
// unmount the open composer, defeating the test.
function Harness({ cwd, sessionKey }: { cwd: string; sessionKey: string }) {
const [repository] = useState(() => ExportedMessageRepository.fromArray([userMessage(), assistantMessage()]))
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
messageRepository: repository,
isRunning: false,
setMessages: () => {},
onNew: noopAsync,
onEdit: noopAsync,
onCancel: noopAsync,
onReload: noopAsync
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread cwd={cwd} sessionKey={sessionKey} />
</AssistantRuntimeProvider>
)
}
describe('thread edit context', () => {
it('passes a same-session cwd change to the mounted edit composer', async () => {
const { rerender } = render(<Harness cwd="/old" sessionKey="k1" />)
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
await screen.findByTestId('edit-composer')
expect(composerRenders.at(-1)?.cwd).toBe('/old')
// Same session, same messages: every ThreadMessageList prop stays
// referentially equal, so only context propagation can reach the
// mounted composer.
await act(async () => {
rerender(<Harness cwd="/new" sessionKey="k1" />)
})
expect(composerRenders.at(-1)?.cwd).toBe('/new')
expect(screen.getByTestId('edit-composer').textContent).toBe('/new')
})
it('still passes the new cwd after a session switch', async () => {
const { rerender } = render(<Harness cwd="/old" sessionKey="k1" />)
await act(async () => {
rerender(<Harness cwd="/new" sessionKey="k2" />)
})
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
await screen.findByTestId('edit-composer')
expect(composerRenders.at(-1)?.cwd).toBe('/new')
})
it('does not remount the transcript when cwd changes', async () => {
// The perf invariant behind keeping cwd out of the memo deps: a cwd
// change with identical messages must not remint the component types,
// so the mounted message DOM nodes survive the rerender.
const { rerender } = render(<Harness cwd="/old" sessionKey="k1" />)
await waitFor(() => {
expect(screen.getByText('done')).toBeTruthy()
expect(screen.getByText('edit me please')).toBeTruthy()
})
const assistantBefore = screen.getByText('done')
const userBefore = screen.getByText('edit me please')
await act(async () => {
rerender(<Harness cwd="/new" sessionKey="k1" />)
})
expect(screen.getByText('done')).toBe(assistantBefore)
expect(screen.getByText('edit me please')).toBe(userBefore)
})
})
@@ -0,0 +1,82 @@
import { render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
/**
* Issue #95595 proposed-fix #3: the `messageComponents` map handed to
* ThreadMessageList must keep its REFERENCE IDENTITY across a session switch.
* If it re-minted, React would unmount/remount every visible message — async
* re-rendered parts (shiki code blocks) collapse and re-expand, and the whole
* thread visibly jumps on every tab switch.
*
* The memo deps are deliberately only the boolean "definedness" gates (the
* callbacks themselves reach the composer through a ref), so a plain switch
* — sessionId changing, callbacks unchanged — must not change the map.
*/
let lastComponents: unknown
vi.mock('@/components/assistant-ui/thread/list', () => ({
ThreadMessageList: (props: { components: unknown }) => {
lastComponents = props.components
return null
}
}))
vi.mock('@/components/assistant-ui/thread/timeline', () => ({
ThreadTimeline: () => null
}))
vi.mock('@/components/assistant-ui/thread/status', () => ({
BackgroundResumeNotice: () => null,
CenteredThreadSpinner: () => null
}))
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
assistant: {
thread: {
restoreBody: 'restore body',
restoreConfirm: 'Restore',
restoreTitle: 'Restore this turn?'
}
},
common: {
cancel: 'Cancel',
confirm: 'Confirm',
done: 'Done',
loading: 'Loading'
}
}
})
}))
import { Thread } from './index'
describe('Thread messageComponents identity across session switches', () => {
it('does not re-mint messageComponents when only the session changes', () => {
const { rerender } = render(<Thread sessionId="session-a" />)
const first = lastComponents
expect(first).toBeDefined()
rerender(<Thread sessionId="session-b" />)
// THE guard: a switch must keep the component map reference, so the
// incoming transcript reconciles instead of remounting.
expect(lastComponents).toBe(first)
rerender(<Thread sessionId="session-c" />)
expect(lastComponents).toBe(first)
})
it('keeps the map stable across a plain parent re-render', () => {
const { rerender } = render(<Thread sessionId="session-a" />)
const first = lastComponents
rerender(<Thread sessionId="session-a" />)
expect(lastComponents).toBe(first)
})
})
@@ -0,0 +1,195 @@
import { createContext, memo, useCallback, useContext, useMemo, useRef, useState } from 'react'
import { ChatEmptySlot } from '@/components/assistant-ui/chat-empty-slot'
import { AssistantMessage } from '@/components/assistant-ui/thread/assistant-message'
import { ThreadMessageList } from '@/components/assistant-ui/thread/list'
import { BackgroundResumeNotice, CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
import { SystemMessage } from '@/components/assistant-ui/thread/system-message'
import { ThreadTimeline } from '@/components/assistant-ui/thread/timeline'
import { type RestoreMessageTarget } from '@/components/assistant-ui/thread/types'
import { UserEditComposer } from '@/components/assistant-ui/thread/user-edit-composer'
import { UserMessage } from '@/components/assistant-ui/thread/user-message'
import { Intro, type IntroProps } from '@/components/chat/intro'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import type { HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications'
type ThreadLoadingState = 'response' | 'session'
interface ThreadEditContextValue {
cwd: string | null
gateway: HermesGateway | null
sessionId: string | null
}
// Edit-composer context. The composer only exists while a message is being
// edited, and it mounts deep inside the memo'd ThreadMessageList, so the
// edit context can neither ride the component-map memo deps (that remints
// the component types on every session switch and remounts the outgoing
// transcript) nor sit in a render-time ref (a mounted composer never
// re-reads it when a same-session change leaves every list prop
// referentially equal). Context solves both: the component type stays
// stable, and a changed value propagates straight to the mounted consumer.
const ThreadEditContext = createContext<ThreadEditContextValue>({ cwd: null, gateway: null, sessionId: null })
interface ThreadProps {
clampToComposer?: boolean
cwd?: string | null
gateway?: HermesGateway | null
intro?: IntroProps
loading?: ThreadLoadingState
onBranchInNewChat?: (messageId: string) => void
onCancel?: () => Promise<void> | void
onDismissError?: (messageId: string) => void
onRestoreToMessage?: (messageId: string, target?: RestoreMessageTarget) => Promise<void> | void
sessionId?: string | null
sessionKey?: string | null
}
// memo'd on purpose, and load-bearing for session-switch cost. ChatView
// re-renders on every route change (it reads `location`), and this subtree is
// the entire transcript — without a bail-out here the router's context update
// rebuilds every message of the OUTGOING thread before it is replaced. The
// props above are all stable across a plain re-render (see the component-map
// and loadingIndicator memos below), so the only thing that gets through is a
// genuine change.
export const Thread = memo(function Thread({
clampToComposer = false,
cwd = null,
gateway = null,
intro,
loading,
onBranchInNewChat,
onCancel,
onDismissError,
onRestoreToMessage,
sessionId = null,
sessionKey
}: ThreadProps) {
const { t } = useI18n()
const copy = t.assistant.thread
const [restoreConfirmTarget, setRestoreConfirmTarget] = useState<
(RestoreMessageTarget & { messageId: string }) | null
>(null)
const closeRestoreConfirm = useCallback(() => setRestoreConfirmTarget(null), [])
const confirmRestore = useCallback(() => {
if (!restoreConfirmTarget || !onRestoreToMessage) {
throw new Error('Restore is unavailable for this message.')
}
const { messageId, text, userOrdinal } = restoreConfirmTarget
closeRestoreConfirm()
void Promise.resolve(onRestoreToMessage(messageId, { text, userOrdinal })).catch((error: unknown) => {
notifyError(error, 'Restore failed')
})
}, [closeRestoreConfirm, onRestoreToMessage, restoreConfirmTarget])
const requestRestoreConfirm = useCallback((messageId: string, target: RestoreMessageTarget) => {
setRestoreConfirmTarget({ messageId, ...target })
}, [])
// The values in this map are component *types*: when their identity
// changes, React unmounts and remounts every visible message — async
// re-rendered parts (shiki code blocks) collapse and re-expand, so the
// whole thread visibly jumps. Parents re-render on unrelated state
// (e.g. the 15s status-snapshot poll in the desktop controller) and
// can't be trusted to keep callback identities stable (see #38333), so
// route the callbacks through a ref instead of listing them as memo
// deps. Only their definedness stays a dep — it gates UI (the user
// Stop button, the restore-confirm affordance). Assigned during render
// (the useStoreSelector pattern) so the ref never lags a render.
//
// cwd / gateway / sessionId stay OUT of the memo deps for the same
// reason: all three change on EVERY session switch, so listing them
// re-minted these types mid-switch and remounted the entire OUTGOING
// transcript — thousands of renders of a thread that was about to be
// replaced, all of it before the resume RPC had even been sent. They
// reach the edit composer through ThreadEditContext instead (see above).
const callbacksRef = useRef({ onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage })
callbacksRef.current = { onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage }
// Only changes identity when one of the three values does, so Thread
// re-renders for unrelated reasons never re-render the composer.
const editContext = useMemo(() => ({ cwd, gateway, sessionId }), [cwd, gateway, sessionId])
const hasBranchInNewChat = Boolean(onBranchInNewChat)
const hasCancel = Boolean(onCancel)
const hasDismissError = Boolean(onDismissError)
const hasRestoreToMessage = Boolean(onRestoreToMessage)
const messageComponents = useMemo(
() => ({
AssistantMessage: () => (
<AssistantMessage
onBranchInNewChat={
hasBranchInNewChat ? messageId => callbacksRef.current.onBranchInNewChat?.(messageId) : undefined
}
onDismissError={hasDismissError ? messageId => callbacksRef.current.onDismissError?.(messageId) : undefined}
/>
),
SystemMessage,
UserEditComposer: () => {
const { cwd: editCwd, gateway: editGateway, sessionId: editSessionId } = useContext(ThreadEditContext)
return <UserEditComposer cwd={editCwd} gateway={editGateway} sessionId={editSessionId} />
},
UserMessage: () => (
<UserMessage
onCancel={hasCancel ? () => callbacksRef.current.onCancel?.() : undefined}
onRequestRestoreConfirm={hasRestoreToMessage ? requestRestoreConfirm : undefined}
/>
)
}),
[hasBranchInNewChat, hasCancel, hasDismissError, hasRestoreToMessage, requestRestoreConfirm]
)
// Core's splash belongs to a fresh draft; a session that exists but has
// nothing in it yet gets whichever plugin owns it. The slot often renders
// nothing, which costs an empty container — harmless, since there is no
// content to lay out until the first message swaps this branch out.
const emptyBody = intro ? <Intro {...intro} /> : sessionId ? <ChatEmptySlot sessionId={sessionId} /> : null
const emptyPlaceholder = emptyBody ? (
<div className="flex min-h-0 w-full flex-col items-center justify-center pt-[var(--composer-measured-height)]">
{emptyBody}
</div>
) : undefined
// Stable element identity, for the same reason the component map above is
// memoized: this is a prop of the memo'd ThreadMessageList, so a fresh
// element every render defeats the bail-out and drags the whole transcript
// into the switch's render pass. It takes no props, so one element is
// always correct.
const loadingIndicator = useMemo(() => <BackgroundResumeNotice />, [])
return (
<ThreadEditContext.Provider value={editContext}>
<div className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
<ThreadMessageList
clampToComposer={clampToComposer}
components={messageComponents}
emptyPlaceholder={emptyPlaceholder}
loadingIndicator={loadingIndicator}
sessionId={sessionId}
sessionKey={sessionKey}
/>
{loading === 'session' && <CenteredThreadSpinner />}
<ThreadTimeline />
<ConfirmDialog
confirmLabel={copy.restoreConfirm}
description={copy.restoreBody}
destructive
onClose={closeRestoreConfirm}
onConfirm={confirmRestore}
open={Boolean(restoreConfirmTarget)}
title={copy.restoreTitle}
/>
</div>
</ThreadEditContext.Provider>
)
})
@@ -0,0 +1,119 @@
// Two contracts with no coverage before the invalidation-scoping work split
// AssistantMessage into InterAgentAssistantMessage + AssistantMessageBody:
//
// 1. The collapse gate. A reply to an inter-agent delivery renders collapsed
// ("Replied to <sender>", expandable) ONLY once it settles — never while it
// streams, because the user should see progress. That gate is the sole
// remaining root-level `isRunning` subscription, so it is the thing most
// likely to break if the split is revisited.
// 2. The streaming marker. `data-message-streaming` moved off the message root
// onto a permanently-mounted hidden leaf, and
// scripts/run-short-session-hang-repro.mjs derives its settled-row count by
// subtracting `[data-message-streaming="true"]` markers from message roots.
// Nothing in the app itself reads it, so without this test a delete would
// look free and would silently regress that repro's response gate.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Thread } from '.'
const createdAt = new Date('2026-05-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
vi.stubGlobal('CSS', { escape: (str: string) => str })
Element.prototype.scrollTo = function scrollTo() {}
Element.prototype.animate = function animate() {
return { cancel() {}, finished: Promise.resolve() } as unknown as Animation
}
afterEach(() => {
cleanup()
})
const assistantMetadata = { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: {} }
function user(id: string, text: string): ThreadMessage {
return {
id,
role: 'user',
content: [{ type: 'text', text }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistant(id: string, text: string, running: boolean): ThreadMessage {
return {
id,
role: 'assistant',
content: text ? [{ type: 'text', text }] : [],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: assistantMetadata
} as ThreadMessage
}
function Harness({ messages }: { messages: ThreadMessage[] }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages,
isRunning: messages.at(-1)?.status?.type === 'running',
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
const DELIVERY = 'Message from 🤖 Hermes (@hermes): please check the build'
describe('inter-agent collapse gate', () => {
it('collapses a settled reply to an inter-agent delivery', async () => {
render(<Harness messages={[user('u1', DELIVERY), assistant('a1', 'build is green', false)]} />)
expect(await screen.findByText(/Replied to/)).toBeTruthy()
expect(screen.getByText('show reply')).toBeTruthy()
})
it('does NOT collapse while that reply is still streaming', async () => {
const { container } = render(<Harness messages={[user('u1', DELIVERY), assistant('a1', 'working on it', true)]} />)
await screen.findByText('working on it')
expect(screen.queryByText('show reply')).toBeNull()
// Expanded => the full body root, which carries the streaming marker.
expect(container.querySelector('[data-message-streaming="true"]')).toBeTruthy()
})
it('leaves an ordinary reply expanded', async () => {
render(<Harness messages={[user('u1', 'ordinary question'), assistant('a1', 'ordinary answer', false)]} />)
await screen.findByText('ordinary answer')
expect(screen.queryByText(/Replied to/)).toBeNull()
})
it('clears the streaming marker once the turn settles', async () => {
const { container } = render(<Harness messages={[user('u1', 'q'), assistant('a1', 'done', false)]} />)
await screen.findByText('done')
expect(container.querySelector('[data-message-streaming="true"]')).toBeNull()
// The marker element itself stays mounted (attribute toggles, no remount).
expect(
container.querySelector('[data-slot="aui_assistant-message-root"] [data-slot="aui_message-streaming-marker"]')
).toBeTruthy()
})
})
@@ -0,0 +1,367 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
buildGroups,
firstVisibleGroupIndex,
HIDDEN_TRANSCRIPT_RENDER_BUDGET,
LIVE_TAIL_MIN_GROUPS,
LIVE_TAIL_PARTS,
liveTailStart,
type MessageGroup,
resolveThreadScrollTarget,
RUN_START_SNAP_THRESHOLD_PX,
shouldClampTranscriptBudget,
shouldRePinOnTranscriptReload,
shouldSnapOnRunStart,
subscribeToThreadForeground,
transcriptBackfillFrameCount,
transcriptPaneBudget
} from './list'
afterEach(() => {
vi.restoreAllMocks()
})
describe('subscribeToThreadForeground', () => {
it('reanchors on focus when an active turn keeps document visibility pinned visible', () => {
const reanchor = vi.fn()
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
callback(0)
return 1
})
const unsubscribe = subscribeToThreadForeground(() => true, reanchor)
window.dispatchEvent(new Event('focus'))
expect(raf).toHaveBeenCalledOnce()
expect(reanchor).toHaveBeenCalledOnce()
unsubscribe()
})
it('leaves a scrolled-up reader in place when the window focuses', () => {
const reanchor = vi.fn()
const raf = vi.spyOn(window, 'requestAnimationFrame')
const unsubscribe = subscribeToThreadForeground(() => false, reanchor)
window.dispatchEvent(new Event('focus'))
expect(raf).not.toHaveBeenCalled()
expect(reanchor).not.toHaveBeenCalled()
unsubscribe()
})
it('drops a queued reanchor when the reader scrolls away before the frame', () => {
const frames: FrameRequestCallback[] = []
let following = true
const reanchor = vi.fn()
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
frames.push(callback)
return 7
})
const unsubscribe = subscribeToThreadForeground(() => following, reanchor)
window.dispatchEvent(new Event('focus'))
following = false
frames[0]?.(0)
expect(reanchor).not.toHaveBeenCalled()
unsubscribe()
})
it('cancels a queued reanchor when its thread unmounts', () => {
const cancel = vi.spyOn(window, 'cancelAnimationFrame')
const reanchor = vi.fn()
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(9)
const unsubscribe = subscribeToThreadForeground(() => true, reanchor)
window.dispatchEvent(new Event('focus'))
unsubscribe()
expect(cancel).toHaveBeenCalledWith(9)
expect(reanchor).not.toHaveBeenCalled()
})
})
// Signature rows are `${index}:${id}:${role}:${weight}` (see the useAuiState
// selector in list.tsx).
const signature = (rows: [string, string, number][]) =>
rows.map(([id, role, weight], index) => `${index}:${id}:${role}:${weight}`).join('\n')
describe('transcriptPaneBudget', () => {
it('uses a fixed live-tail budget while hidden instead of charging every mounted transcript', () => {
expect(transcriptPaneBudget(1, true)).toBe(HIDDEN_TRANSCRIPT_RENDER_BUDGET)
expect(transcriptPaneBudget(4, true)).toBe(HIDDEN_TRANSCRIPT_RENDER_BUDGET)
expect(transcriptPaneBudget(1, false)).toBeGreaterThan(HIDDEN_TRANSCRIPT_RENDER_BUDGET)
})
})
describe('shouldClampTranscriptBudget', () => {
it('never snaps a visible pane back after Show earlier', () => {
expect(shouldClampTranscriptBudget(false, 10, 5)).toBe(false)
expect(shouldClampTranscriptBudget(false, 5, 5)).toBe(false)
})
it('snaps only a hot-hidden pane that outgrew the retention budget', () => {
expect(shouldClampTranscriptBudget(true, 10, 5)).toBe(true)
expect(shouldClampTranscriptBudget(true, 5, 5)).toBe(false)
})
})
describe('buildGroups', () => {
it('returns no groups for an empty signature', () => {
expect(buildGroups('')).toEqual([])
})
it('groups a user message with the assistant turn(s) that follow it', () => {
const groups = buildGroups(
signature([
['u1', 'user', 1],
['a1', 'assistant', 4],
['a2', 'assistant', 2],
['u2', 'user', 1],
['a3', 'assistant', 3]
])
)
expect(groups).toEqual([
{ id: 'u1', indices: [0, 1, 2], kind: 'turn', weight: 7 },
{ id: 'u2', indices: [3, 4], kind: 'turn', weight: 4 }
])
})
it('keeps leading non-user messages as standalone groups', () => {
const groups = buildGroups(
signature([
['s1', 'system', 1],
['a0', 'assistant', 2],
['u1', 'user', 1],
['a1', 'assistant', 5]
])
)
expect(groups).toEqual([
{ id: 's1', index: 0, kind: 'standalone', weight: 1 },
{ id: 'a0', index: 1, kind: 'standalone', weight: 2 },
{ id: 'u1', indices: [2, 3], kind: 'turn', weight: 6 }
])
})
it('defaults a missing/zero weight to 1', () => {
const groups = buildGroups('0:a:assistant:0')
expect(groups).toEqual([{ id: 'a', index: 0, kind: 'standalone', weight: 1 }])
})
})
describe('resolveThreadScrollTarget', () => {
const context = (scrollElement: Pick<HTMLElement, 'scrollTop'>) => ({
contentElement: document.createElement('div'),
scrollElement: scrollElement as HTMLElement
})
it('settles when the browser clamps the requested bottom within half a CSS pixel', () => {
let actualScrollTop = 0
let writes = 0
const scrollElement = {
get scrollTop() {
return actualScrollTop
},
set scrollTop(value: number) {
writes += 1
actualScrollTop = value - 0.125
}
}
const target = 899
const requested = resolveThreadScrollTarget(target, context(scrollElement))
scrollElement.scrollTop = requested
const settled = resolveThreadScrollTarget(target, context(scrollElement))
expect(requested).toBe(target)
expect(actualScrollTop).toBe(898.875)
expect(settled).toBe(actualScrollTop)
expect(actualScrollTop < settled).toBe(false)
expect(writes).toBe(1)
})
it('keeps following while more than half a CSS pixel remains', () => {
const scrollElement = { scrollTop: 898.25 }
expect(resolveThreadScrollTarget(899, context(scrollElement))).toBe(899)
})
it('re-arms after streaming content increases the target', () => {
const scrollElement = { scrollTop: 898.875 }
expect(resolveThreadScrollTarget(899, context(scrollElement))).toBe(898.875)
expect(resolveThreadScrollTarget(999, context(scrollElement))).toBe(999)
})
})
describe('firstVisibleGroupIndex', () => {
const group = (id: string, weight: number): MessageGroup => ({ id, index: 0, kind: 'standalone', weight })
it('shows everything when total weight fits the budget', () => {
const groups = [group('a', 10), group('b', 10), group('c', 10)]
expect(firstVisibleGroupIndex(groups, 100)).toBe(0)
})
it('walks newest-first and hides everything before the turn that meets the budget', () => {
const groups = [group('old', 50), group('mid', 30), group('new', 30)]
// newest-first: 30 (new) < 60, +30 (mid) = 60 >= 60 → mid is the first
// visible group, old is hidden.
expect(firstVisibleGroupIndex(groups, 60)).toBe(1)
})
it('keeps whole turns intact — the turn that crosses the budget stays visible', () => {
const groups = [group('old', 5), group('huge', 500)]
expect(firstVisibleGroupIndex(groups, 60)).toBe(1)
})
it('returns groups.length for an empty list', () => {
expect(firstVisibleGroupIndex([], 60)).toBe(0)
})
it('keeps a floor of turns visible however heavy they are', () => {
// Without the floor a session of enormous turns puts "Show earlier" two
// turns from the bottom, which reads as broken rather than as paging.
const groups = Array.from({ length: 20 }, (_, i) => group(`g${i}`, 5_000))
expect(firstVisibleGroupIndex(groups, 600, 8)).toBe(groups.length - 8)
})
it('does not force the floor to hide turns the budget already showed', () => {
const groups = Array.from({ length: 20 }, (_, i) => group(`g${i}`, 1))
expect(firstVisibleGroupIndex(groups, 600, 8)).toBe(0)
})
})
describe('liveTailStart', () => {
const group = (id: string, weight: number): MessageGroup => ({ id, index: 0, kind: 'standalone', weight })
it('keeps the newest turns rendered until the parts budget is spent', () => {
// 10 turns x 10 parts. A 40-part tail covers the newest 4-5 turns.
const groups = Array.from({ length: 10 }, (_, i) => group(`g${i}`, 10))
const start = liveTailStart(groups)
expect(start).toBeGreaterThan(0)
expect(start).toBeLessThan(groups.length)
// Everything from `start` onward is the live tail...
const tailParts = groups.slice(start).reduce((sum, g) => sum + g.weight, 0)
expect(tailParts).toBeGreaterThan(LIVE_TAIL_PARTS)
// ...and dropping its oldest member puts it back under budget, i.e. the
// tail is minimal rather than sprawling.
const withoutOldest = groups.slice(start + 1).reduce((sum, g) => sum + g.weight, 0)
expect(withoutOldest).toBeLessThanOrEqual(LIVE_TAIL_PARTS)
})
it('virtualizes the old bulk of a long agent transcript', () => {
// The regression this guards: heavy tool turns. A turn-count tail (6) left
// NOTHING virtualized on transcripts like this, so every Radix overlay open
// paid a whole-document style recalc.
const groups = Array.from({ length: 40 }, (_, i) => group(`g${i}`, 120))
// Only the min-group floor stays rendered; the other 38 turns skip.
expect(liveTailStart(groups)).toBe(groups.length - LIVE_TAIL_MIN_GROUPS)
})
it('never virtualizes below the min-group floor, however heavy the turns', () => {
const groups = Array.from({ length: 5 }, (_, i) => group(`g${i}`, 10_000))
expect(liveTailStart(groups)).toBe(groups.length - LIVE_TAIL_MIN_GROUPS)
})
it('keeps every turn rendered when the whole transcript fits in the tail', () => {
const groups = [group('a', 5), group('b', 5), group('c', 5)]
expect(liveTailStart(groups)).toBe(0)
})
it('handles an empty transcript', () => {
expect(liveTailStart([])).toBe(0)
})
it('honors a custom budget', () => {
const groups = Array.from({ length: 10 }, (_, i) => group(`g${i}`, 1))
// A 3-part budget would keep 4 turns, but the max-groups ceiling is not hit
// here, so the parts budget wins.
expect(liveTailStart(groups, 3)).toBe(6)
})
it('never renders more than the old turn-count tail did, on any shape', () => {
// Guards the one way a parts budget can regress: a long transcript of tiny
// turns, where walking back 40 parts reaches further than 6 turns would.
const shapes = [
Array.from({ length: 40 }, () => 4), // long chat, tiny turns
Array.from({ length: 40 }, () => 1), // pathological: 1-part turns
Array.from({ length: 12 }, () => 6),
[80, 120, 60, 150, 90, 200, 70], // real agent tile
[30, 45]
]
for (const weights of shapes) {
const groups = weights.map((weight, i) => group(`g${i}`, weight))
const rendered = (start: number) => weights.slice(start).reduce((a, b) => a + b, 0)
const oldStart = Math.max(0, groups.length - 6)
expect(rendered(liveTailStart(groups))).toBeLessThanOrEqual(rendered(oldStart))
}
})
})
describe('transcriptBackfillFrameCount', () => {
it('settles a full pane in at most three prepend commits', () => {
expect(transcriptBackfillFrameCount()).toBeLessThanOrEqual(3)
})
})
describe('shouldSnapOnRunStart', () => {
it('snaps when the viewport is already at the bottom', () => {
expect(shouldSnapOnRunStart(0)).toBe(true)
})
it('snaps when the viewport is a line or two off the bottom', () => {
expect(shouldSnapOnRunStart(RUN_START_SNAP_THRESHOLD_PX - 1)).toBe(true)
})
it('leaves a reader who has scrolled into history alone', () => {
expect(shouldSnapOnRunStart(RUN_START_SNAP_THRESHOLD_PX)).toBe(false)
expect(shouldSnapOnRunStart(400)).toBe(false)
})
})
describe('shouldRePinOnTranscriptReload', () => {
it('pins on a session switch even before the transcript has settled', () => {
expect(shouldRePinOnTranscriptReload({ sessionSwitched: true, settledNonEmpty: false })).toBe(true)
})
it('pins on a session switch even when the prior session had settled', () => {
expect(shouldRePinOnTranscriptReload({ sessionSwitched: true, settledNonEmpty: true })).toBe(true)
})
it('preserves the reader position on a same-session refresh after settling', () => {
expect(shouldRePinOnTranscriptReload({ sessionSwitched: false, settledNonEmpty: true })).toBe(false)
})
it('pins on a cold-load arrival (same session, never settled non-empty)', () => {
expect(shouldRePinOnTranscriptReload({ sessionSwitched: false, settledNonEmpty: false })).toBe(true)
})
})
@@ -0,0 +1,860 @@
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import {
type ComponentProps,
type CSSProperties,
type FC,
memo,
type ReactNode,
startTransition,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState
} from 'react'
import { type GetTargetScrollTop, useStickToBottom } from 'use-stick-to-bottom'
import { usePaneLifecycle, usePaneVisible } from '@/components/pane-shell/pane-visibility'
import { useI18n } from '@/i18n'
import { messagePaintWeight } from '@/lib/render-weight'
import { cn } from '@/lib/utils'
import {
onScrollToBottomRequest,
onThreadEditClose,
onThreadEditOpen,
publishThreadAtBottom,
resetPublishedThreadScroll
} from '@/store/thread-scroll'
import { isSecondaryWindow } from '@/store/windows'
import { MessageRenderBoundary } from '../message-render-boundary'
import { resolveShowEarlierAction, useTranscriptWindow } from './transcript-window'
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
export type MessageGroup = { id: string; weight: number } & (
{ index: number; kind: 'standalone' } | { indices: number[]; kind: 'turn' }
)
// DOM is bounded by a render-cost budget, not a message/turn count. The
// currency is `messagePaintWeight`: what a turn actually MOUNTS, which is what
// the grouping decides rather than what the payload weighs. A settled run of
// twelve reads is one grey summary line, a thought is one collapsed
// disclosure, a hoisted `todo` is nothing — while a diff, an image card or a
// wall of markdown really does build DOM and is charged for it.
//
// Pricing by payload instead had the budget counting work that never mounts:
// one tool-heavy turn measured 84-281 units of tool JSON that painted as a
// dozen one-line summaries, so a session spent the whole page in two or three
// turns and offered "Show earlier" over a screen and a half of transcript.
//
// "Show earlier" prepends another page; whole turns stay intact so the sticky
// human bubble never loses its turn. This is the long-session perf lever WITHOUT
// a virtualizer — pure rendering, never touches scrollTop, so it can't fight
// use-stick-to-bottom (the single scroll owner).
//
// 600 units ≈ 10-20 agentic turns on measured real sessions (a tool-heavy turn
// prices at 30-90, a plain exchange at 5-10), and a whole session of ordinary
// work now fits one page instead of paging three times to reach its start.
// What the DOM can hold is bounded above by the store window regardless
// (TRANSCRIPT_WINDOW_BUDGET), so this cannot admit more than one window's
// content.
const RENDER_BUDGET = 600
// Every mounted transcript list registers here (see the mount effect). The
// budget above is sized for ONE full-height pane; a grid split shows several
// panes at once, each a fraction of the screen — yet each was still mounting
// the full budget. Four visible panes meant 4x the mounted message fibers,
// and every streaming flush pays selector re-runs and React commit traversal
// over ALL of them — measured as the 4-zone collapse in the long-session
// matrix (worst-second 8fps while 1-2 zones held 50+). Sharing the budget
// keeps "screens of scrollback" constant instead of "turns per pane": a pane
// a quarter the height gets a quarter the page, floored at a quarter budget
// (MIN_VISIBLE_GROUPS still floors the turn count regardless of weight).
// Panes that already backfilled keep their mounted content when the count
// changes — the share only caps where NEW backfills stop.
const $mountedTranscriptPanes = atom(0)
// Never offer "Show earlier" over fewer turns than this, however heavy they
// are. A weight-only cut on a session of enormous turns put the button two
// turns from the bottom, where it reads as broken rather than as paging — the
// user has not been given enough transcript to have gone looking for more. The
// store window caps what the DOM can reach at all, so a floor here stays
// bounded.
const MIN_VISIBLE_GROUPS = 8
// On session switch, paint a small budget first (enough for the bottom turn(s)
// the user actually sees after scroll-to-bottom), then bump to the full budget
// in a requestAnimationFrame — defers the heavy markdown+syntax-highlight render
// past the initial commit, so the switch feels instant.
//
// 20, down from 60: the first-paint commit is synchronous and uninterruptible,
// and at 60 cost units it measured 627ms on a real session (LoAF: block=575ms, no
// attributed script — pure commit). A viewport after scroll-to-bottom shows
// 1-2 normal turns ≈ 10-20 units; the transition backfill below fills the rest
// interruptibly, so the only thing a smaller budget changes is how much work
// blocks the click-to-paint path.
const FIRST_PAINT_BUDGET = 20
// A hot-hidden transcript is retained for instant tab return, but keeping its
// full scrollback mounted defeats the bounded pane cache. Preserve only the
// live tail while hidden; revealing it resumes stepped backfill.
export const HIDDEN_TRANSCRIPT_RENDER_BUDGET = 40
export const transcriptPaneBudget = (mountedPanes: number, hidden: boolean): number =>
hidden
? HIDDEN_TRANSCRIPT_RENDER_BUDGET
: Math.max(Math.ceil(RENDER_BUDGET / Math.max(1, mountedPanes)), RENDER_BUDGET / 4)
// "Show earlier" raises renderBudget ABOVE paneBudget (one pane page per click).
// The render-phase cap must only snap a hot-hidden pane down to its retention
// budget — a visible pane's growth has to survive the next render or the click
// is a no-op. Parked panes are unmounted, so they never hit this path.
export const shouldClampTranscriptBudget = (hidden: boolean, renderBudget: number, paneBudget: number): boolean =>
hidden && renderBudget > paneBudget
// Units the backfill adds per committed step (see the backfill effect). A
// 60-unit step produced ~10 visible prepend frames after FIRST_PAINT_BUDGET
// retune (#83681). 290 fills a 600-unit page in two interruptible commits —
// still well under the measured 780ms single-jump freeze.
const BACKFILL_STEP = 290
export const transcriptBackfillFrameCount = (
firstPaint = FIRST_PAINT_BUDGET,
step = BACKFILL_STEP,
budget = RENDER_BUDGET
): number => Math.ceil(Math.max(0, budget - firstPaint) / step)
// Browsers may quantize a requested scrollTop to a nearby device-pixel
// boundary. use-stick-to-bottom otherwise compares the lower actual value to
// the integer target forever, re-requesting the same instant scroll every
// frame. Treat a subpixel remainder as achieved; larger gaps still follow new
// streamed content normally.
const SCROLL_TARGET_EPSILON_PX = 0.5
export const resolveThreadScrollTarget: GetTargetScrollTop = (targetScrollTop, { scrollElement }) => {
const currentScrollTop = scrollElement.scrollTop
const remaining = targetScrollTop - currentScrollTop
return remaining >= 0 && remaining <= SCROLL_TARGET_EPSILON_PX ? currentScrollTop : targetScrollTop
}
/** Near-bottom slack for a run-start snap. Wider than the subpixel epsilon
* use-stick-to-bottom uses for resize follow — a follow-up sent a line or two
* off the bottom should still track, but a reader in history must not yank. */
export const RUN_START_SNAP_THRESHOLD_PX = 64
export function shouldSnapOnRunStart(remainingPx: number, thresholdPx = RUN_START_SNAP_THRESHOLD_PX): boolean {
return remainingPx < thresholdPx
}
// True when the pin-to-bottom settle should re-arm. A same-session refresh
// (transcript briefly emptied and repopulated under the same key) must keep
// the reader's position; only a session switch or a cold-load arrival re-pins.
export function shouldRePinOnTranscriptReload(opts: { sessionSwitched: boolean; settledNonEmpty: boolean }): boolean {
return opts.sessionSwitched || !opts.settledNonEmpty
}
export function subscribeToThreadForeground(shouldReanchor: () => boolean, onReanchor: () => void): () => void {
let frameId: number | null = null
let framePending = false
const onForeground = () => {
if (framePending || document.visibilityState !== 'visible' || !shouldReanchor()) {
return
}
framePending = true
const scheduledId = requestAnimationFrame(() => {
frameId = null
framePending = false
if (document.visibilityState === 'visible' && shouldReanchor()) {
onReanchor()
}
})
// Browser callbacks are asynchronous; the guard also keeps synchronous
// requestAnimationFrame test doubles from leaving a completed frame pending.
if (framePending) {
frameId = scheduledId
}
}
document.addEventListener('visibilitychange', onForeground)
window.addEventListener('focus', onForeground)
return () => {
document.removeEventListener('visibilitychange', onForeground)
window.removeEventListener('focus', onForeground)
if (frameId !== null) {
cancelAnimationFrame(frameId)
}
frameId = null
framePending = false
}
}
interface ThreadMessageListProps {
clampToComposer: boolean
components: ThreadMessageComponents
emptyPlaceholder?: ReactNode
loadingIndicator?: ReactNode
sessionId?: string | null
sessionKey?: string | null
}
// Group each user message with the assistant turn(s) that follow it so the
// human bubble can `position: sticky` against the scroller across its whole
// turn (see StickyHumanMessageContainer in thread.tsx).
export function buildGroups(signature: string): MessageGroup[] {
if (!signature) {
return []
}
const messages = signature.split('\n').map(row => {
const [index, id, role, weight] = row.split(':')
return { id, index: Number(index), role, weight: Number(weight) || 1 }
})
const groups: MessageGroup[] = []
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
if (message.role !== 'user') {
groups.push({ id: message.id, index: message.index, kind: 'standalone', weight: message.weight })
continue
}
const indices = [message.index]
let weight = message.weight
while (i + 1 < messages.length && messages[i + 1].role !== 'user') {
weight += messages[++i].weight
indices.push(messages[i].index)
}
groups.push({ id: message.id, indices, kind: 'turn', weight })
}
return groups
}
// Walk turns newest-first, summing their render weights until the budget is met;
// everything before the first kept turn is hidden. `minVisible` turns are kept
// regardless of weight. Returns the index of that first visible group.
export function firstVisibleGroupIndex(groups: readonly MessageGroup[], budget: number, minVisible = 0): number {
let firstVisible = groups.length
for (let i = groups.length - 1, weight = 0; i >= 0; i--) {
weight += groups[i].weight
firstVisible = i
if (weight >= budget) {
break
}
}
return Math.min(firstVisible, Math.max(0, groups.length - minVisible))
}
// content-visibility:auto skips off-screen turns for perf, but with
// contain-intrinsic-size:auto the browser only remembers a turn's size AFTER
// it has rendered. A turn that finishes streaming near the bottom may have had
// its (smaller) mid-stream size remembered; when it scrolls just off the top
// edge and gets skipped, it snaps back to that stale height, shifting content
// down. With overflow-anchor:none (the viewport can't self-correct) the
// stick-to-bottom lock drifts and the view creeps up over older turns — the
// "long session eventually shows old responses" glitch.
//
// Keep the newest turns always-rendered so a turn is only ever virtualized
// once its layout has settled at its final size (remembered == real → skipping
// it changes no height). Off-screen OLDER turns still skip, so the dialog/popover
// recalc win on long transcripts is preserved.
//
// The tail is budgeted in render-cost units, not turns, because that is what the
// cost actually scales with — the same currency as RENDER_BUDGET /
// FIRST_PAINT_BUDGET.
// A turn-count tail silently defeats itself on agent transcripts: one tool-heavy
// turn is 50-200 units, so a 6-TURN tail exempted the entire visible transcript
// and nothing virtualized at all. Measured on a 5-tile window (7/3/5/3/2 groups
// per tile): zero content-visibility containers were active, and every Radix
// overlay open paid the full ~610ms whole-document recalc that #66470 fixed.
//
// 40 units ≈ the 1-2 turns a viewport shows after scroll-to-bottom (the same
// reasoning as FIRST_PAINT_BUDGET=20, doubled so a turn that grows mid-stream
// doesn't fall out of the tail as it settles).
export const LIVE_TAIL_PARTS = 40
// Floor: always exempt at least this many turns regardless of weight, so a
// transcript of very heavy turns still keeps the streaming one unvirtualized.
export const LIVE_TAIL_MIN_GROUPS = 2
// Ceiling: never exempt more than this many turns, however light they are. On a
// long transcript of tiny turns a weight-only budget would walk back further
// than the old turn-count tail did and virtualize LESS — this keeps the new
// policy a strict improvement on every shape.
export const LIVE_TAIL_MAX_GROUPS = 6
/**
* Index of the newest group that still virtualizes — everything at or after it
* is the live tail and stays rendered. Walks newest-first accumulating weight,
* so the tail covers a viewport's worth of content rather than a fixed number
* of turns, clamped to [MIN, MAX] turns. Computed once per render, not per row.
*/
export function liveTailStart(
groups: readonly MessageGroup[],
tailWeight = LIVE_TAIL_PARTS,
minGroups = LIVE_TAIL_MIN_GROUPS,
maxGroups = LIVE_TAIL_MAX_GROUPS
): number {
let weight = 0
let start = groups.length
for (let i = groups.length - 1; i >= 0; i--) {
weight += groups[i]?.weight ?? 1
start = i
if (weight > tailWeight) {
break
}
}
// Clamp the tail to [minGroups, maxGroups] turns: the floor keeps the live
// turn rendered when turns are huge, the ceiling stops a tail of tiny turns
// from sprawling past what the old turn-count policy rendered.
const floor = Math.max(0, groups.length - minGroups)
const ceiling = Math.max(0, groups.length - maxGroups)
return Math.min(floor, Math.max(ceiling, start))
}
interface TurnRowProps {
components: ThreadMessageComponents
group: MessageGroup
resetKey: string
virtualized: boolean
}
// One turn (or standalone message) of the transcript. memo() is the point:
// the rows array below is REBUILT whenever the DOM budget's cut advances
// (hiddenCount changes its slice), and without per-row bail-out that rebuild
// re-rendered every mounted turn — markdown, code cards, tool blocks — in one
// synchronous frame, a 100-800ms stall once a second on a streaming long
// session. With memo, a rebuild re-renders only rows whose props changed:
// the dropped head row unmounts, the virtualization boundary rows flip their
// flag, and everything else bails on identical group/resetKey identity.
//
// content-visibility:auto (virtualized rows) — off-screen turns skip style
// recalc, layout, and paint. On a long transcript this is what keeps
// UNRELATED UI fast: any dialog/popover mount (Radix Presence reads
// getComputedStyle) forces a whole-document style recalc, measured
// ~650-730ms per open on a 1300-message session and ~100-200ms with this
// on. contain-intrinsic-size keeps a placeholder height for never-rendered
// turns (auto: remembered real size once rendered), so scrollbar/anchoring
// stay stable. Sticky human bubbles are unaffected — their turn is rendered
// whenever any part of it intersects the viewport.
//
// The live tail (newest turns) is exempt: virtualizing a turn whose final
// size hasn't been remembered yet snaps it to a stale height when it scrolls
// off, drifting stick-to-bottom up over old turns. See liveTailStart.
const TurnRow = memo(function TurnRow({ components, group, resetKey, virtualized }: TurnRowProps) {
return (
<div
className={cn(
'flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)',
virtualized && '[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]'
)}
>
<MessageRenderBoundary resetKey={resetKey}>
{group.kind === 'turn' ? (
<div
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} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
</MessageRenderBoundary>
</div>
)
})
const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
clampToComposer,
components,
emptyPlaceholder,
loadingIndicator,
sessionId = null,
sessionKey
}) => {
// TWO signatures, deliberately split. The STRUCTURAL one (ids/roles/count)
// changes only when messages are added/removed/swapped — it keys the error
// boundaries and the row identity. The WEIGHT one (parts + character cost)
// ticks while a streaming turn appends content — it feeds only the render
// budget. Folding weights into the structural key handed every boundary a
// new resetKey per appended part, which reconciled every turn's subtree on
// 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')
)
const weightSignature = useAuiState(s =>
s.thread.messages.map(message => messagePaintWeight(message.content)).join(',')
)
const { t } = useI18n()
// Row structure is memoized on the STRUCTURAL signature only, so streaming
// part-appends can't churn group identity (that would defeat the rows memo
// below on every tick). Weights are folded in separately for the budget.
const groups = useMemo(() => buildGroups(structuralSignature), [structuralSignature])
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
// use-stick-to-bottom owns scrollTop (single writer): follow while locked,
// escape on user scroll-up, re-lock at bottom. Snap instantly, not spring — a
// spring can't tell live-token growth from a session-switch bulk relayout, and
// chasing the latter reads as the view scrolling to random spots before
// settling. Its refs hang off our own DOM so the sticky human bubbles survive.
const { scrollRef, contentRef, isAtBottom, scrollToBottom, stopScroll } = useStickToBottom({
initial: 'instant',
resize: 'instant',
targetScrollTop: resolveThreadScrollTarget
})
const { olderAvailable, expandWindow } = useTranscriptWindow()
useEffect(() => {
$mountedTranscriptPanes.set($mountedTranscriptPanes.get() + 1)
return () => $mountedTranscriptPanes.set($mountedTranscriptPanes.get() - 1)
}, [])
const mountedPanes = useStore($mountedTranscriptPanes)
const paneLifecycle = usePaneLifecycle()
const paneVisible = usePaneVisible()
// Hidden panes retain only a live-tail budget. Visible panes share the normal
// screen budget; a reveal backfills older rows in bounded transition steps.
const paneBudget = transcriptPaneBudget(mountedPanes, paneLifecycle === 'hot-hidden')
const [renderBudget, setRenderBudget] = useState(FIRST_PAINT_BUDGET)
// Cut the budget during RENDER, not in the post-commit layout effect. An
// effect-time cut is too late: React would first build the whole tree with
// the full budget (up to 300 cost units of markdown + syntax highlighting),
// commit it, and only then re-render at the small budget. The render-phase
// state adjustment restarts this component immediately — before any child
// renders — so the heavy commit never happens.
//
// Two triggers, because the transcript swap arrives differently per path:
// a WARM switch publishes sessionKey + messages in one commit (the key
// branch), while a COLD switch changes sessionKey with an empty transcript
// and the prefetched messages land hundreds of ms later under the SAME key
// (the empty→non-empty branch).
const hasGroups = groups.length > 0
const [budgetSessionKey, setBudgetSessionKey] = useState(sessionKey)
const [hadGroups, setHadGroups] = useState(hasGroups)
if (budgetSessionKey !== sessionKey) {
setBudgetSessionKey(sessionKey)
setHadGroups(hasGroups)
setRenderBudget(FIRST_PAINT_BUDGET)
} else if (shouldClampTranscriptBudget(paneLifecycle === 'hot-hidden', renderBudget, paneBudget)) {
// Apply the hidden budget during render so React never first commits the
// stale full transcript after this pane moves to the background.
setRenderBudget(paneBudget)
} else if (hadGroups !== hasGroups) {
setHadGroups(hasGroups)
if (hasGroups) {
setRenderBudget(FIRST_PAINT_BUDGET)
}
}
// Where to land after a prepend, in distance-from-bottom (survives the
// height change). Shared by "Show earlier" and the budget backfill below.
const restoreFromBottomRef = useRef<number | null>(null)
// False from a session switch until the settle loop below parks the
// transcript at its true bottom. While false, scrollTop is a way-point of a
// load in progress, not a reading position anyone chose — never anchor to it.
const loadSettledRef = useRef(false)
// Session the settle loop last armed for, so a re-arm within the same load
// is distinguishable from a switch to a different transcript.
const settleKeyRef = useRef(sessionKey)
// True once the CURRENT session has settled with a non-empty transcript.
// A same-session refresh must keep the reader's position; only a switch or
// a cold-load arrival re-arms. Reset on switch so a mid-settle key change
// cannot inherit the outgoing session's settled flag.
const settledNonEmptyRef = useRef(false)
// Record where the view should land once a prepend has grown the content,
// measured from the BOTTOM so the added height doesn't invalidate it. Only a
// settled load has an offset the user chose; mid-load the answer is simply
// the bottom.
const anchorBeforePrepend = useCallback(() => {
const el = scrollRef.current
restoreFromBottomRef.current = el && loadSettledRef.current ? el.scrollHeight - el.scrollTop : 0
}, [scrollRef])
// Backfill from FIRST_PAINT_BUDGET to the full budget after the small
// commit painted — as a TRANSITION, so the heavy markdown + syntax
// highlight render of the older turns is interruptible instead of one long
// synchronous commit that freezes input right after the switch. Route
// changes stay urgent (main.tsx disables router transitions); it's exactly
// this backfill that belongs at background priority. "Show earlier" pages
// (budget > paneBudget) never re-enter here.
//
// In BOUNDED STEPS, not one jump to the full budget. A transition render is
// interruptible but its COMMIT is not, and one 20→600 step commits every
// backfilled turn at once — measured as a 780ms uninterruptible frame when
// the session was revealed while other tiles streamed (the flushes kept
// interrupting the transition, which finally landed whole, seconds later,
// mid-stream). Each step commits at most BACKFILL_STEP units; the effect
// re-arms off the committed budget, so steps pace one per frame.
useEffect(() => {
if (renderBudget >= paneBudget) {
return
}
const rafId = requestAnimationFrame(() => {
// The backfill PREPENDS older turns, so everything on screen slides down
// by their height. Anchor first and let the restore effect below re-apply
// it in the same commit the taller tree lands in — otherwise the view is
// stranded near the TOP until use-stick-to-bottom's ResizeObserver
// catches up a frame or two later (measured: an 11.5k px jump showing
// ~160ms of unrelated old turns, on every session load).
anchorBeforePrepend()
// Functional max, not a plain set: an urgent "Show earlier" click can
// land between scheduling and committing this transition, and a plain
// set would rebase over it and shrink the budget back down.
startTransition(() => setRenderBudget(budget => Math.max(budget, Math.min(budget + BACKFILL_STEP, paneBudget))))
})
return () => cancelAnimationFrame(rafId)
}, [anchorBeforePrepend, paneBudget, renderBudget])
// Weights (part count + visible character cost) fold into the BUDGET only.
// Group identity stays structural, so a streaming append re-runs this cheap
// sum — not the row JSX. Settled content hits messagePaintWeight's WeakMap.
const weightedGroups = useMemo(() => {
const weights = weightSignature.split(',').map(w => Number(w) || 1)
return groups.map(group => ({
...group,
weight:
group.kind === 'turn'
? group.indices.reduce((sum, index) => sum + (weights[index] ?? 1), 0)
: (weights[group.index] ?? 1)
}))
}, [groups, weightSignature])
// The turn floor applies to a real page only. During the first-paint budget
// the point is a small synchronous commit; forcing 8 turns into it would put
// back exactly the freeze FIRST_PAINT_BUDGET exists to avoid, and the rAF
// backfill a frame later fills them in anyway.
const hiddenCount = firstVisibleGroupIndex(
weightedGroups,
renderBudget,
renderBudget >= paneBudget ? MIN_VISIBLE_GROUPS : 0
)
// Memoized for IDENTITY, not to save the slice: `rows` below keys off this
// array, and an inline slice handed it a fresh array every render — so the
// moment a transcript outgrew the render budget (hiddenCount > 0), every
// streamed token rebuilt every visible row's JSX and re-rendered the whole
// mounted transcript. Under the budget the raw `groups` identity made the
// memo hold; heavy sessions lost it exactly when they could least afford to.
const visibleGroups = useMemo(() => (hiddenCount > 0 ? groups.slice(hiddenCount) : groups), [groups, hiddenCount])
// Where the always-rendered live tail begins. Derived from the WEIGHTED
// groups (render cost, not turns) so the tail is a viewport's worth of content —
// see liveTailStart. Computed once here rather than per row.
const tailStart = useMemo(
() => liveTailStart(hiddenCount > 0 ? weightedGroups.slice(hiddenCount) : weightedGroups),
[weightedGroups, hiddenCount]
)
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
// hide the titlebar tool cluster + session header, but the OS traffic lights
// still sit in the top-left, so reserve the titlebar gap above the transcript.
const secondaryWindow = isSecondaryWindow()
// NB: CSS calc() requires whitespace around the +/- operator. This string is
// assigned verbatim to the --sticky-human-top inline style below (it does not
// go through Tailwind, which would auto-space it), so the spaces are load-
// bearing — without them the declaration is invalid, gets dropped, and the
// sticky user bubble falls back to its ~4px default and slides under the OS
// traffic lights.
const secondaryTitlebarGap = 'calc(var(--titlebar-height) + 0.75rem)'
const threadContentTopPad = secondaryWindow
? 'pt-[calc(var(--titlebar-height)+0.75rem)]'
: 'pt-[calc(var(--titlebar-height)-0.5rem)]'
useEffect(() => publishThreadAtBottom(isAtBottom, { paneVisible }), [isAtBottom, paneVisible])
useEffect(() => () => resetPublishedThreadScroll({ paneVisible }), [paneVisible])
// Floating jump button (outside this subtree) → return to the bottom.
useEffect(() => onScrollToBottomRequest(() => void scrollToBottom(), sessionId), [scrollToBottom, sessionId])
// Waking from display: hidden (HUD mode hides the main window; OS hide does
// the same to any window): rAF and ResizeObserver may have been frozen, so
// the virtualizer's measurements — and scrollTop itself — are stale. Active
// turns disable Chromium's background throttling, which can keep visibility
// pinned at `visible`; window focus is then the only foreground edge. If the
// user was following the bottom, re-anchor on either signal. Consult this
// thread's local state rather than the composer-facing global mirror, which
// can be overwritten by another mounted pane; leave a scrolled-up reader
// exactly where they were.
useEffect(
() =>
subscribeToThreadForeground(
() => isAtBottom,
() => void scrollToBottom()
),
[isAtBottom, scrollToBottom]
)
const endEditHold = useCallback(() => {
scrollRef.current?.removeAttribute('data-editing')
}, [scrollRef])
// Inline edit grows a sticky bubble. Escape before focus/layout so the
// resize-follow can't snap scrollTop; native anchoring holds the viewport.
const beginEditHold = useCallback(() => {
const el = scrollRef.current
if (!el) {
return
}
endEditHold()
stopScroll()
el.setAttribute('data-editing', 'true')
}, [endEditHold, scrollRef, stopScroll])
useEffect(() => onThreadEditOpen(beginEditHold), [beginEditHold])
useEffect(() => onThreadEditClose(endEditHold), [endEditHold])
useEffect(() => () => endEditHold(), [endEditHold])
// New run → snap to the latest turn only when already near the bottom.
useAuiEvent('thread.runStart', () => {
const el = scrollRef.current
if (el && shouldSnapOnRunStart(el.scrollHeight - el.scrollTop - el.clientHeight)) {
scrollToBottom()
}
})
// Reset the cap and pin to bottom on mount + every session switch (messages
// swap in place on a long-lived runtime, so sessionKey is the only signal).
// The swap is multi-step and lays out over many frames; letting the library
// follow re-pins every frame to a moving target — visible as ~10 scroll jumps.
// Instead: quiet it, glue to the true bottom until the height holds steady,
// then hand back locked. Live streaming afterward uses the normal resize follow.
//
// `hasGroups` joins sessionKey as a dep because a COLD load changes the key
// while the transcript is still empty and publishes messages hundreds of ms
// later. Keyed on the switch alone the loop measured an EMPTY viewport, saw
// a stable height in two frames, and handed back "settled" before the
// transcript existed — so the turns painted at scrollTop 0 and only snapped
// down once use-stick-to-bottom's ResizeObserver noticed, a full-viewport
// lurch on every cold load. The empty→non-empty flip re-arms for the
// transcript that actually arrived; being a boolean, it cannot re-fire on a
// streaming append.
useLayoutEffect(() => {
const el = scrollRef.current
if (!el) {
return
}
const sessionSwitched = settleKeyRef.current !== sessionKey
if (sessionSwitched) {
settledNonEmptyRef.current = false
}
// Same-session refresh (transcript briefly cleared and repopulated) must
// keep the reader's position. Run before stopScroll / scrollTop reset so
// a refresh neither yanks the view nor clears the settled flag.
if (!shouldRePinOnTranscriptReload({ sessionSwitched, settledNonEmpty: settledNonEmptyRef.current })) {
return
}
stopScroll()
el.scrollTop = el.scrollHeight
loadSettledRef.current = false
// An anchor captured for the OUTGOING transcript must not be applied to
// this one — a switch owns the position outright. The empty→non-empty
// re-arm is the SAME load, whose in-flight anchor is still correct.
if (sessionSwitched) {
settleKeyRef.current = sessionKey
restoreFromBottomRef.current = null
}
let frame = 0
let stableFrames = 0
let lastHeight = el.scrollHeight
const settle = () => {
const node = scrollRef.current
if (!node) {
return
}
const height = node.scrollHeight
stableFrames = height === lastHeight ? stableFrames + 1 : 0
lastHeight = height
node.scrollTop = height
// Most session switches are synchronous and stabilize within 2 frames;
// the old 90-frame ceiling was for slow async image loads. Cap at 15
// frames to minimize the settle-loop racing markdown paint on every switch.
if (stableFrames >= 2 || ++frame > 15) {
void scrollToBottom('instant')
settledNonEmptyRef.current = hasGroups
loadSettledRef.current = true
return
}
rafId = requestAnimationFrame(settle)
}
let rafId = requestAnimationFrame(settle)
return () => cancelAnimationFrame(rafId)
}, [hasGroups, scrollRef, scrollToBottom, sessionKey, stopScroll])
// Prepend an older page while preserving the on-screen position. The user is
// scrolled up (reading history) so the stick-to-bottom lock is escaped and
// won't fight this manual restore. Spend the already-materialized DOM page
// first; only when that is exhausted pull more messages out of the session
// store (#55191).
const showEarlier = useCallback(() => {
const action = resolveShowEarlierAction(hiddenCount, olderAvailable)
if (!action) {
return
}
anchorBeforePrepend()
// Both paths grow the DOM budget by one pane page. Windowed rows are older
// than the current page, so expand-without-grow paints nothing.
setRenderBudget(budget => budget + paneBudget)
if (action === 'window') {
expandWindow()
}
}, [anchorBeforePrepend, expandWindow, hiddenCount, olderAvailable, paneBudget])
useLayoutEffect(() => {
const el = scrollRef.current
if (el && restoreFromBottomRef.current != null) {
el.scrollTop = el.scrollHeight - restoreFromBottomRef.current
restoreFromBottomRef.current = null
}
// renderBudget covers DOM pages; groups.length covers store-window expands.
}, [scrollRef, renderBudget, groups.length])
// The row array is memoized on the inputs the rows actually read. This
// component re-renders on every isAtBottom flip — and use-stick-to-bottom
// flips it from a ResizeObserver, so a sidebar DRAG re-renders this list per
// frame. Without the memo, the inline .map() rebuilt every row's JSX each
// time, and rebuilt children re-render their whole subtree even when nothing
// changed (measured live: 865 wasted Block renders in one drag, walked to
// "MessageRenderBoundary (children only)" by explain()). With it, React
// bails out on element identity and a scroll flip re-renders nothing below.
const rows = useMemo(
() =>
visibleGroups.map((group, indexInVisible) => (
<TurnRow
components={components}
group={group}
key={group.id}
resetKey={structuralSignature}
virtualized={indexInVisible < tailStart}
/>
)),
[visibleGroups, components, structuralSignature, tailStart]
)
return (
<div
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
style={
{
height: clampToComposer ? 'var(--thread-viewport-height)' : '100%',
...(secondaryWindow ? { '--sticky-human-top': secondaryTitlebarGap } : {})
} as CSSProperties
}
>
{secondaryWindow && (
// Secondary windows hide the titlebar chrome, so the scroller runs to
// the window's top edge and streamed text slides up under the OS
// traffic lights. Content padding alone scrolls away with the text — a
// fixed opaque strip (the titlebar's drag region) masks anything behind
// it and keeps the window draggable, matching the main window's header.
<div
aria-hidden="true"
className="absolute inset-x-0 top-0 z-10 h-(--titlebar-height) bg-background [-webkit-app-region:drag]"
/>
)}
<div
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
data-following={isAtBottom ? 'true' : 'false'}
data-slot="aui_thread-viewport"
ref={scrollRef as React.RefCallback<HTMLDivElement>}
>
{renderEmpty ? (
<div
className="mx-auto grid h-full w-full max-w-(--composer-width) grid-rows-[minmax(0,1fr)_auto] min-w-0 gap-(--conversation-turn-gap) px-6 py-8"
data-slot="aui_thread-content"
>
{emptyPlaceholder}
</div>
) : (
<div
className={cn('mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6', threadContentTopPad)}
data-slot="aui_thread-content"
ref={contentRef as React.RefCallback<HTMLDivElement>}
>
{(hiddenCount > 0 || olderAvailable) && (
<button
className="mx-auto mb-(--conversation-turn-gap) rounded-full border border-border/65 bg-(--composer-fill) px-3 py-1 text-xs text-muted-foreground hover:text-foreground"
onClick={showEarlier}
type="button"
>
{t.assistant.thread.showEarlier}
</button>
)}
{rows}
{loadingIndicator}
{clampToComposer && (
<div
aria-hidden="true"
className="shrink-0"
data-slot="aui_composer-clearance"
style={{ height: 'var(--thread-last-message-clearance)' }}
/>
)}
</div>
)}
</div>
</div>
)
}
export const ThreadMessageList = memo(ThreadMessageListInner)
@@ -0,0 +1,356 @@
import {
type ReasoningMessagePartComponent,
type TextMessagePartProps,
type ToolCallMessagePartProps,
useAuiState,
useMessagePartReasoning
} from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type ComponentProps, type FC, type ReactNode, useEffect, useRef, useState } from 'react'
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
import { McpSetupTool } from '@/components/assistant-ui/mcp-setup-tool'
import { AgentDeliveryNotice, deliveryTargetFromCommand } from '@/components/assistant-ui/thread/agent-delivery'
import { TimelineTimestamp } from '@/components/assistant-ui/thread/timeline-timestamp'
import { DelegateTool } from '@/components/assistant-ui/tool/delegate'
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool/fallback'
import { formatElapsed, useElapsedSeconds, useMeasuredDuration } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { GeneratedImage } from '@/components/chat/generated-image-result'
import { SCAFFOLD_LABEL_CLASS, SCAFFOLD_META_CLASS, ScaffoldRow } from '@/components/chat/scaffold-row'
import { useI18n } from '@/i18n'
import { generatedImageFromResult } from '@/lib/generated-images'
import { separateGluedReasoningBlocks } from '@/lib/reasoning-blocks'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { $reasoningCollapsedByDefault } from '@/store/reasoning-disclosure'
type TimelineToolCallProps = ToolCallMessagePartProps & { completedAt?: number; timestamp?: number }
const ImageGenerateTool: FC<TimelineToolCallProps> = props => {
const { args, completedAt, result, timestamp } = props
const aspectRatio = typeof args?.aspect_ratio === 'string' ? args.aspect_ratio : undefined
// The image card owns successful generations. Failed or malformed results
// still need the normal tool row: it extracts the error text and gives the
// user an honest, expandable failure rather than silently dropping the call.
if (result !== undefined && !generatedImageFromResult(result)) {
return <ToolFallback {...props} />
}
return (
<div className="mt-1.5">
<TimelineTimestamp className="mb-0.5 block" completedAt={completedAt} timestamp={timestamp} />
<GeneratedImage aspectRatio={aspectRatio} result={result} />
</div>
)
}
const DelegateToolPart: FC<TimelineToolCallProps> = props => {
// A call that failed outright dispatched nothing — there are no children to
// list, only an error. The generic row extracts and expands it properly.
if (props.isError) {
return <ToolFallback {...props} />
}
return (
<>
<TimelineTimestamp className="mb-0.5 block" completedAt={props.completedAt} timestamp={props.timestamp} />
<DelegateTool args={props.args} result={props.result} toolCallId={props.toolCallId} />
</>
)
}
const ChainToolFallback: FC<TimelineToolCallProps> = props => {
// todo parts are hoisted to a dedicated panel above the message content.
if (props.toolName === 'todo') {
return null
}
// An inter-agent delivery run through the terminal tool renders as the
// compact "Messaged X" / "Message from X" notices, not a transcript row
// (Grok-bots parity; the receiving side already renders notices via
// AGENT_MESSAGE_RE). Non-delivery terminal calls fall through unchanged.
if (props.toolName === 'terminal' && !props.isError) {
const command = typeof props.args?.command === 'string' ? props.args.command : ''
if (deliveryTargetFromCommand(command)) {
return <AgentDeliveryNotice {...props} />
}
}
// A reaction's UI is the emoji landing on the bubble (message.reaction
// event) — a "React To Message" tool block next to it would be the agent
// narrating its own tapback. Failures still render so they're debuggable.
if (props.toolName === 'react_to_message' && !props.isError) {
return null
}
if (props.toolName === 'delegate_task') {
return <DelegateToolPart {...props} />
}
if (props.toolName === 'image_generate') {
return <ImageGenerateTool {...props} />
}
if (props.toolName === 'clarify') {
return (
<>
<TimelineTimestamp className="mb-0.5 block" completedAt={props.completedAt} timestamp={props.timestamp} />
<ClarifyTool {...props} />
</>
)
}
if (props.toolName === 'setup_mcp') {
return <McpSetupTool {...props} />
}
return <ToolFallback {...props} />
}
type TimelineTextPartProps = TextMessagePartProps & { completedAt?: number; timestamp?: number }
const TimelineMarkdownText: FC<TimelineTextPartProps> = ({ completedAt, timestamp }) => (
<>
<TimelineTimestamp className="mb-0.5 block" completedAt={completedAt} timestamp={timestamp} />
<MarkdownText />
</>
)
const ThinkingDisclosure: FC<{
children: ReactNode
completedAt?: number
messageRunning?: boolean
pending?: boolean
timestamp?: number
// Required: the block's duration is remembered against this key, so a
// component that mounts after the block finished can still report it.
timerKey: string
}> = ({ children, completedAt, messageRunning = false, pending = false, timestamp, timerKey }) => {
const { t } = useI18n()
const reasoningCollapsedByDefault = useStore($reasoningCollapsedByDefault)
// `null` = no explicit user toggle yet. Live reasoning remains visible by
// default, unless the user opts into the low-jitter collapsed presentation.
const [userOpen, setUserOpen] = useState<boolean | null>(null)
const elapsed = useElapsedSeconds(pending, timerKey)
const thoughtFor = useMeasuredDuration(pending, timerKey)
const scrollRef = useRef<HTMLDivElement | null>(null)
const contentRef = useRef<HTMLDivElement | null>(null)
const enterRef = useEnterAnimation(messageRunning, timerKey)
// A live preview that later settles must not unmount its body — that is the
// "turn settled and everything jumped" shift. Latch that we showed one so
// the clip stays. Groups that mount already complete (earlier thoughts in
// a still-running turn) never latch, so they stay collapsed.
const [sawLivePreview, setSawLivePreview] = useState(false)
if (pending && !sawLivePreview) {
setSawLivePreview(true)
}
// The collapsed-by-default preference outranks the latch: it opts out of
// live previews entirely, so there is nothing to hold open.
const showPreview = !reasoningCollapsedByDefault && (pending || sawLivePreview)
const open = userOpen ?? showPreview
const isPreview = userOpen === null && showPreview
// Three ways a finished block can report itself. With a measured duration it
// says so, unless the timer's whole seconds round it to "0s" — accurate and
// useless — in which case it just says it was quick. With no duration at all
// it still has to read as finished; a turn that ended must not go on saying
// "Thinking".
let thoughtLabel = t.assistant.thread.thinking
if (!pending) {
if (thoughtFor === null) {
thoughtLabel = t.assistant.thread.thought
} else if (thoughtFor < 1) {
thoughtLabel = t.assistant.thread.thoughtBriefly
} else {
thoughtLabel = t.assistant.thread.thoughtFor(formatElapsed(thoughtFor))
}
}
// While the preview is live, pin the scroll container to the bottom on
// every content growth so the latest tokens are always visible.
useEffect(() => {
if (!isPreview) {
return
}
const el = scrollRef.current
const content = contentRef.current
if (!el || !content) {
return
}
// Height-gated: the observer also fires when the container's WIDTH changes
// (sidebar sash drag resizes every message), and pinning there forces a
// scrollHeight read+write per preview per frame. Only actual content
// growth needs the pin; the height rides the RO entry, reflow-free.
let lastHeight = -1
const pin = (entries: readonly ResizeObserverEntry[]) => {
const height = entries[entries.length - 1]?.borderBoxSize?.[0]?.blockSize ?? -1
const grew = height < 0 || height > lastHeight
lastHeight = height
if (grew) {
el.scrollTop = el.scrollHeight
}
}
// No sync pin(): the observer's guaranteed initial delivery runs it with
// layout already clean (still before paint), avoiding a forced reflow.
const observer = new ResizeObserver(pin)
observer.observe(content)
return () => observer.disconnect()
// Re-run when the disclosure toggles so the observer attaches to the new
// DOM after expand/collapse (refs are conditionally rendered on `open`).
}, [isPreview, open])
return (
<div
className="text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)"
data-conversation-scaffold=""
data-slot="aui_thinking-disclosure"
ref={enterRef}
>
<ScaffoldRow
onToggle={() => setUserOpen(!open)}
open={open}
trailing={
<span className="flex shrink-0 items-center gap-1.5">
<TimelineTimestamp className={SCAFFOLD_META_CLASS} completedAt={completedAt} timestamp={timestamp} />
{pending && <ActivityTimerText className={SCAFFOLD_META_CLASS} seconds={elapsed} />}
</span>
}
>
<span className={cn(SCAFFOLD_LABEL_CLASS, pending && 'shimmer')}>{thoughtLabel}</span>
</ScaffoldRow>
{open && (
<div
className={cn(
// Body sits flush with the "Thinking" header — no left indent —
// and inherits the disclosure-level opacity fade defined in
// styles.css (~0.67 at rest, 1 on hover/focus). overflow-auto so
// the max-h-40 preview is a real scroller, not a clip.
'mt-0.5 w-full min-w-0 max-w-full overflow-auto overscroll-contain wrap-anywhere pb-1',
isPreview && 'max-h-40'
)}
data-slot="aui_thinking-body"
ref={scrollRef}
>
<div ref={contentRef}>{children}</div>
</div>
)}
</div>
)
}
// Self-gate "Thinking…" on this message's own reasoning parts. Reading
// `thread.isRunning` directly would flicker shimmer/timer on every old
// assistant whenever the external-store runtime clears+reimports its
// repository (one ref-identity bump per streaming delta).
const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; startIndex: number }> = ({
children,
endIndex,
startIndex
}) => {
const messageId = useAuiState(s => s.message.id)
const messageRunning = useAuiState(s => s.message.status?.type === 'running')
const pending = useAuiState(
s =>
s.thread.isRunning &&
s.message.status?.type === 'running' &&
s.message.parts
.slice(Math.max(0, startIndex), endIndex + 1)
.some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
)
// A reasoning group with no actual text is pure noise — drop the whole
// "Thinking" disclosure rather than leave an empty header eating a row. This
// applies live too: encrypted/spinner-coerced reasoning (Opus reasoning max)
// never carries visible text, and the bottom-of-thread loader already signals
// "thinking", so an empty header is never wanted. Real reasoning surfaces the
// instant its first token lands.
const hasContent = useAuiState(s =>
s.message.parts
.slice(Math.max(0, startIndex), endIndex + 1)
.some(p => p?.type === 'reasoning' && typeof p.text === 'string' && p.text.trim().length > 0)
)
const timestamp = useAuiState(s =>
s.message.parts.slice(Math.max(0, startIndex), endIndex + 1).reduce<number | undefined>((earliest, part) => {
const value = part.type === 'reasoning' ? (part as { timestamp?: number }).timestamp : undefined
return value === undefined ? earliest : earliest === undefined ? value : Math.min(earliest, value)
}, undefined)
)
const completedAt = useAuiState(s =>
s.message.parts.slice(Math.max(0, startIndex), endIndex + 1).reduce<number | undefined>((latest, part) => {
const value = part.type === 'reasoning' ? (part as { completedAt?: number }).completedAt : undefined
return value === undefined ? latest : latest === undefined ? value : Math.max(latest, value)
}, undefined)
)
if (!hasContent) {
return null
}
return (
// Keyed per block, not per message: the timer registry hands every caller
// of a key the same origin, so a turn that thinks three separate times used
// to measure the second and third blocks from the first one's start and
// report the running total as each block's duration.
<ThinkingDisclosure
completedAt={completedAt}
messageRunning={messageRunning}
pending={pending}
timerKey={`reasoning:${messageId}:${startIndex}`}
timestamp={timestamp}
>
{children}
</ThinkingDisclosure>
)
}
// Read the part from context, same contract as MarkdownText's
// useMessagePartText — the reasoning-only smoothing wrapper (removed) stalled
// the char-reveal at empty, blanking the widget.
const ReasoningTextPart: ReasoningMessagePartComponent = () => {
const { status, text } = useMessagePartReasoning()
const messageRunning = useAuiState(s => s.message.status?.type === 'running')
return (
<MarkdownTextContent
containerClassName="text-xs leading-snug text-muted-foreground/85"
containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>}
disableArtifacts
isRunning={status.type === 'running' || messageRunning}
text={separateGluedReasoningBlocks(text.trimStart())}
/>
)
}
// Module-level constant so the `components` prop on `MessagePrimitive.Parts`
// has a stable identity across renders. Without this every AssistantMessage
// render would create a fresh `components` object, invalidating the memo on
// `MessagePrimitivePartByIndex` and forcing every tool/reasoning child to
// re-render on every streaming delta. Memo invalidation alone doesn't
// remount, but combined with the previous ToolFallback group-swap it was a
// big chunk of the per-delta work.
export const MESSAGE_PARTS_COMPONENTS = {
Reasoning: ReasoningTextPart,
ReasoningGroup: ReasoningAccordionGroup,
Text: TimelineMarkdownText,
ToolGroup: ToolGroupSlot,
tools: { Fallback: ChainToolFallback }
} as const
@@ -0,0 +1,208 @@
import { EmojiPicker } from 'frimousse'
import { type FC, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { triggerHaptic } from '@/lib/haptics'
import { Plus } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { QUICK_REACTIONS } from '@/store/reactions'
import type { MessageReaction } from '@/types/hermes'
// Served from the app's own origin (vite.config.ts `hermes:emojibase-assets`
// plugin bundles emojibase-data): Electron must work offline, and the app
// should never phone a CDN to draw a picker.
const EMOJIBASE_URL = './emojibase'
// Slack tints its picker cells in a repeating palette (green, blue, yellow,
// pink, brown, purple…) so long scrolls stay scannable. Same trick, in the
// app's own accent idiom (bg-emerald-500/15 etc. are existing patterns).
// Keyed off the emoji's codepoint — deterministic, and stable under
// frimousse's virtualized rows (an index cycle would reshuffle on scroll).
const CELL_TINTS = [
'hover:bg-emerald-500/15 data-[active]:bg-emerald-500/20',
'hover:bg-sky-500/15 data-[active]:bg-sky-500/20',
'hover:bg-amber-500/15 data-[active]:bg-amber-500/20',
'hover:bg-pink-500/15 data-[active]:bg-pink-500/20',
'hover:bg-orange-500/15 data-[active]:bg-orange-500/20',
'hover:bg-violet-500/15 data-[active]:bg-violet-500/20'
] as const
const cellTint = (emoji: string) => CELL_TINTS[(emoji.codePointAt(0) ?? 0) % CELL_TINTS.length]
/** The full emoji picker, revealed behind the quick row's "+". Headless — styled here. */
const FullEmojiPicker: FC<{ onSelect: (emoji: string) => void }> = ({ onSelect }) => (
<EmojiPicker.Root
className="flex h-72 w-76 flex-col"
emojibaseUrl={EMOJIBASE_URL}
onEmojiSelect={emoji => onSelect(emoji.emoji)}
>
{/* Borderless, underline-on-focus — the app's SearchField idiom (DESIGN.md),
not a boxed search bar. Search matches labels AND emojibase tags
("lol" → 😂), which frimousse handles natively. */}
<EmojiPicker.Search
autoFocus
className="mx-1 border-b border-(--ui-stroke-tertiary) bg-transparent px-1 pb-1 text-sm outline-hidden focus:border-(--ui-stroke-secondary)"
placeholder="Search…"
/>
<EmojiPicker.Viewport className="relative flex-1 outline-hidden">
<EmojiPicker.Loading className="absolute inset-0 grid place-items-center text-xs text-(--ui-text-tertiary)">
Loading emoji
</EmojiPicker.Loading>
<EmojiPicker.Empty className="absolute inset-0 grid place-items-center text-xs text-(--ui-text-tertiary)">
No emoji found.
</EmojiPicker.Empty>
<EmojiPicker.List
className="select-none pb-1"
components={{
CategoryHeader: ({ category, ...props }) => (
<div
className="bg-(--ui-bg-elevated) px-1.5 pt-2 pb-1 text-[0.6875rem] text-(--ui-text-tertiary)"
{...props}
>
{category.label}
</div>
),
Emoji: ({ emoji, ...props }) => (
<button
className={cn('grid size-8 shrink-0 place-items-center rounded-md text-lg', cellTint(emoji.emoji))}
{...props}
>
{emoji.emoji}
</button>
),
Row: ({ children, ...props }) => (
<div className="flex px-1" {...props}>
{children}
</div>
)
}}
/>
</EmojiPicker.Viewport>
</EmojiPicker.Root>
)
/**
* The reaction picker — six quick emoji, then "+" for the full set.
*
* Rides the shared Popover, so it inherits the app's menu/popover surface
* treatment rather than inventing a floating pill (DESIGN.md: popovers get one
* shared shadow + hairline; call sites don't reinvent elevation).
*/
export const ReactionPicker: FC<{
align?: 'end' | 'start'
children: React.ReactNode
onOpenChange: (open: boolean) => void
onSelect: (emoji: string) => void
open: boolean
selected?: string
}> = ({ align = 'end', children, onOpenChange, onSelect, open, selected }) => {
const [expanded, setExpanded] = useState(false)
return (
<Popover
onOpenChange={next => {
onOpenChange(next)
if (!next) {
// Always reopen on the quick row.
setExpanded(false)
}
}}
open={open}
>
<PopoverAnchor asChild>{children}</PopoverAnchor>
<PopoverContent
align={align}
// Opt this one surface out of the shared popover glass: emoji hover
// tints at 15% alpha are unreadable over blurred transcript text.
// Overriding the local surface var keeps the arrow matched for free.
className={cn('w-auto p-1 [--popover-surface:var(--ui-bg-elevated)]', !expanded && 'flex gap-0.5')}
onCloseAutoFocus={event => event.preventDefault()}
side="top"
>
{expanded ? (
<FullEmojiPicker onSelect={onSelect} />
) : (
<>
{QUICK_REACTIONS.map(emoji => (
<Button
aria-label={emoji}
aria-pressed={selected === emoji}
className={cn('text-base', selected === emoji && 'bg-(--chrome-action-hover)')}
key={emoji}
onClick={() => {
triggerHaptic('selection')
onSelect(emoji)
}}
size="icon-sm"
variant="ghost"
>
{emoji}
</Button>
))}
<Button aria-label="More emoji" onClick={() => setExpanded(true)} size="icon-sm" variant="ghost">
<Plus />
</Button>
</>
)}
</PopoverContent>
</Popover>
)
}
/**
* The reactions a message carries.
*
* Flat by design (DESIGN.md: "Flat, not boxed") — no pill, no border, no fill.
* It reads as quiet metadata in the same register as the message age and the
* checkpoint row it sits beside. Your own reaction is clickable to retract;
* the agent's is display-only.
*/
export const ReactionBadge: FC<{
className?: string
onRetract?: () => void
reactions: MessageReaction[]
}> = ({ className, onRetract, reactions }) => {
if (!reactions.length) {
return null
}
return (
<span
className={cn('flex items-center gap-1 text-[0.8125rem] leading-none', className)}
data-slot="aui_msg-reactions"
>
{reactions.map(reaction =>
reaction.author === 'user' && onRetract ? (
<button
aria-label={`Remove ${reaction.emoji} reaction`}
className="reaction-pop cursor-pointer leading-none transition-transform hover:scale-110 active:scale-95"
key={`${reaction.author}-${reaction.emoji}`}
onClick={event => {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onRetract()
}}
onPointerDown={event => {
event.preventDefault()
event.stopPropagation()
}}
type="button"
>
{reaction.emoji}
</button>
) : (
<span
className="reaction-pop leading-none"
key={`${reaction.author}-${reaction.emoji}`}
title="Reacted by Hermes"
>
{reaction.emoji}
</span>
)
)}
</span>
)
}
@@ -0,0 +1,100 @@
// Link previews moved off the message root into the AssistantPreviewEmbeds
// leaf, because the selector behind them (`'' while running`, the full
// `messageContentText(content)` join once settled) flipped on every
// running <-> settled transition and re-rendered the root with it.
//
// Two things are pinned here. That the embed still renders at all — the move
// was verbatim JSX and had no coverage before. And that it renders ONLY once
// the turn settles: the '' branch is a deliberate streaming optimization (it
// keeps the selector referentially stable so per-token flushes skip the regex
// scan), so a rewrite that drops it would make previews flicker in mid-stream
// with nothing to catch it.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Thread } from '.'
const createdAt = new Date('2026-05-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
vi.stubGlobal('CSS', { escape: (str: string) => str })
Element.prototype.scrollTo = function scrollTo() {}
Element.prototype.animate = function animate() {
return { cancel() {}, finished: Promise.resolve() } as unknown as Animation
}
afterEach(() => {
cleanup()
})
const assistantMetadata = { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: {} }
function user(id: string, text: string): ThreadMessage {
return {
id,
role: 'user',
content: [{ type: 'text', text }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistant(id: string, text: string, running: boolean): ThreadMessage {
return {
id,
role: 'assistant',
content: text ? [{ type: 'text', text }] : [],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: assistantMetadata
} as ThreadMessage
}
function Harness({ messages }: { messages: ThreadMessage[] }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages,
isRunning: messages.at(-1)?.status?.type === 'running',
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
const TARGET = 'https://example.com/docs'
const WITH_PREVIEW = `Serving now: [Preview: example](#preview/${TARGET})`
describe('settled-turn link previews', () => {
it('renders the embed once the turn has settled', async () => {
const { container } = render(<Harness messages={[user('u1', 'start it'), assistant('a1', WITH_PREVIEW, false)]} />)
await screen.findByText('Serving now:', { exact: false })
expect(container.querySelector(`[title="${TARGET}"]`)).toBeTruthy()
})
it('does not render the embed while the turn is still running', async () => {
const { container } = render(<Harness messages={[user('u1', 'start it'), assistant('a1', WITH_PREVIEW, true)]} />)
await screen.findByText('Serving now:', { exact: false })
expect(container.querySelector(`[title="${TARGET}"]`)).toBeNull()
})
})
@@ -0,0 +1,166 @@
// The invalidation-scoping property, as a render-count contract.
//
// A streaming turn flips its message status many times a second, and at
// stream breadth N that is N status flips per flush. The whole point of this
// work is that a flip re-renders only the small leaves that actually display
// status — never the message ROOT, whose subtree is the entire rendered
// message and whose re-render is what widened style recalculation to document
// scope.
//
// That property is invisible to a DOM assertion: the transcript looks
// identical either way. So this counts renders instead. AssistantMessageBody
// is the root component, and `useTapbackDoubleClick` is called by it and by
// nothing else in the tree, which makes it an exact render counter for the
// root without needing to export or wrap an internal component.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as messageReactionsModule from '@/components/assistant-ui/thread/use-message-reactions'
import { Thread } from '.'
let rootRenders = 0
vi.mock('@/components/assistant-ui/thread/use-message-reactions', async importActual => {
const actual = await importActual<typeof messageReactionsModule>()
return {
...actual,
useTapbackDoubleClick: (messageId: string, role: 'assistant' | 'user') => {
if (role === 'assistant') {
rootRenders += 1
}
return actual.useTapbackDoubleClick(messageId, role)
}
}
})
const createdAt = new Date('2026-05-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
vi.stubGlobal('CSS', { escape: (str: string) => str })
Element.prototype.scrollTo = function scrollTo() {}
Element.prototype.animate = function animate() {
return { cancel() {}, finished: Promise.resolve() } as unknown as Animation
}
beforeEach(() => {
rootRenders = 0
})
afterEach(() => {
cleanup()
})
const assistantMetadata = { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: {} }
function user(id: string, text: string): ThreadMessage {
return {
id,
role: 'user',
content: [{ type: 'text', text }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistant(id: string, text: string, running: boolean): ThreadMessage {
return {
id,
role: 'assistant',
content: text ? [{ type: 'text', text }] : [],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: assistantMetadata
} as ThreadMessage
}
function Harness({ messages }: { messages: ThreadMessage[] }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages,
isRunning: messages.at(-1)?.status?.type === 'running',
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
describe('streaming-status invalidation scope', () => {
it('does not re-render the message root when the turn settles', async () => {
const messages = [user('u1', 'question'), assistant('a1', 'partial answer', true)]
const { container, findByText, rerender } = render(<Harness messages={messages} />)
await findByText('partial answer')
// The leaf carries the streaming flag while the turn is in flight.
expect(container.querySelector('[data-message-streaming="true"]')).toBeTruthy()
const rendersWhileStreaming = rootRenders
rerender(<Harness messages={[messages[0], assistant('a1', 'partial answer', false)]} />)
// The leaf saw the flip...
await waitFor(() => {
expect(container.querySelector('[data-message-streaming="true"]')).toBeNull()
})
// ...on the same permanently-mounted node (attribute toggle, no remount)...
expect(container.querySelector('[data-slot="aui_message-streaming-marker"]')).toBeTruthy()
// ...and the root did not re-render for it.
expect(rootRenders).toBe(rendersWhileStreaming)
})
it('does not re-render the message root when streaming text arrives', async () => {
const messages = [user('u1', 'question'), assistant('a1', 'one', true)]
const { findByText, rerender } = render(<Harness messages={messages} />)
await findByText('one')
const rendersAfterFirstToken = rootRenders
// A delta flush: same status, more text. The root must not subscribe to
// the streaming text either — only the markdown part re-renders.
rerender(<Harness messages={[messages[0], assistant('a1', 'one two', true)]} />)
await findByText('one two')
expect(rootRenders).toBe(rendersAfterFirstToken)
})
it('keeps the same root DOM node across the settle transition', async () => {
// The inter-agent reply collapses once it settles. Rendering that as a
// competing root swapped the element type at this position, so React
// unmounted the row and mounted a fresh one — discarding the DOM the
// scroll anchor was holding.
const delivery = 'Message from 🤖 Hermes (@hermes): please check the build'
const messages = [user('u1', delivery), assistant('a1', 'working on it', true)]
const { container, findByText, rerender } = render(<Harness messages={messages} />)
await findByText('working on it')
const before = container.querySelector('[data-slot="aui_assistant-message-root"]')
expect(before).toBeTruthy()
rerender(<Harness messages={[messages[0], assistant('a1', 'working on it', false)]} />)
await findByText(/Replied to/)
const after = container.querySelector('[data-slot="aui_assistant-message-root"]')
// Same element, updated in place — not a remount.
expect(after).toBe(before)
})
})
@@ -0,0 +1,72 @@
// The thinking indicator (dither block) may only ever render at the TAIL of
// the thread. A message stuck status:running mid-transcript — however it got
// there (missed settle event, steer race, upstream state bug) — must render
// its content with no spinner: a live indicator above a later user message
// reads as the agent answering out of order.
import { type ThreadMessage } from '@assistant-ui/react'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { stubThreadEnvironment, ThreadRuntime, userMessage } from '../test-utils'
import { Thread } from '.'
const createdAt = new Date('2026-05-01T00:00:00.000Z')
stubThreadEnvironment()
afterEach(() => {
cleanup()
})
const assistantMetadata = {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
function assistant(id: string, text: string, running: boolean): ThreadMessage {
return {
id,
role: 'assistant',
content: text ? [{ type: 'text', text }] : [],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: assistantMetadata
} as ThreadMessage
}
const Harness = ({ messages }: { messages: ThreadMessage[] }) => (
<ThreadRuntime messages={messages}>
<Thread />
</ThreadRuntime>
)
describe('thinking indicator is tail-only', () => {
it('shows the loading indicator on a running placeholder at the tail', async () => {
const { container } = render(<Harness messages={[userMessage('u1', 'question'), assistant('a1', '', true)]} />)
expect(await screen.findByRole('status', { name: 'Hermes is loading a response' })).toBeTruthy()
expect(container.querySelector('[data-slot="aui_response-loading"]')).toBeTruthy()
})
it('never shows an indicator on a stale running message mid-transcript', async () => {
// A stranded pending bubble from an earlier turn, then a newer exchange.
const { container } = render(
<Harness
messages={[
userMessage('u1', 'first question'),
assistant('a1', '', true),
userMessage('u2', 'second question'),
assistant('a2', 'answered', false)
]}
/>
)
await screen.findByText('answered')
expect(container.querySelector('[data-slot="aui_response-loading"]')).toBeNull()
expect(container.querySelector('[data-slot="aui_turn-activity"]')).toBeNull()
})
})
@@ -0,0 +1,88 @@
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { __resetElapsedTimerRegistryForTests } from '@/components/chat/activity-timer'
import { I18nProvider } from '@/i18n'
import { $providerWaitSessions, setSessionProviderWait } from '@/store/provider-wait'
import { $activeSessionId, $turnStartedAt } from '@/store/session'
import { ResponseLoadingIndicator } from './status'
function renderIndicator() {
return render(
<I18nProvider configClient={null} initialLocale="en">
<ResponseLoadingIndicator />
</I18nProvider>
)
}
describe('ResponseLoadingIndicator timer', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
// useViewedInterval gates ticking on document focus + visibility; jsdom's
// hasFocus() is unreliable across runners, so pin it (same as the
// background-sync backstop tests).
vi.spyOn(globalThis.document, 'hasFocus').mockReturnValue(true)
__resetElapsedTimerRegistryForTests()
})
afterEach(() => {
cleanup()
$activeSessionId.set(null)
$turnStartedAt.set(null)
$providerWaitSessions.set({})
__resetElapsedTimerRegistryForTests()
vi.restoreAllMocks()
vi.useRealTimers()
})
it('preserves each running session timer while switching between sessions', () => {
$activeSessionId.set('session-a')
$turnStartedAt.set(Date.now())
const sessionA = renderIndicator()
act(() => vi.advanceTimersByTime(5_000))
expect(screen.getAllByText((_, node) => node?.textContent === '5s').length).toBeGreaterThan(0)
sessionA.unmount()
$activeSessionId.set('session-b')
$turnStartedAt.set(Date.now())
const sessionB = renderIndicator()
act(() => vi.advanceTimersByTime(3_000))
expect(screen.getAllByText((_, node) => node?.textContent === '3s').length).toBeGreaterThan(0)
sessionB.unmount()
$activeSessionId.set('session-a')
$turnStartedAt.set(new Date('2026-01-01T00:00:00.000Z').getTime())
renderIndicator()
expect(screen.getAllByText((_, node) => node?.textContent === '8s').length).toBeGreaterThan(0)
})
it('names a prolonged provider wait in the existing response status row', () => {
$activeSessionId.set('session-a')
$turnStartedAt.set(Date.now())
setSessionProviderWait('session-a', '⏳ waiting on local-model — 30s with no output yet')
renderIndicator()
expect(screen.getByText('⏳ waiting on local-model — 30s with no output yet')).toBeTruthy()
})
})
// The status line sits between tool rows and thinking headers, which the
// transcript rests at a fade. Without the mark it reads a shade brighter than
// both — the one line in the column claiming emphasis it hasn't earned.
describe('status line', () => {
afterEach(cleanup)
it('is marked as transcript scaffolding', () => {
$activeSessionId.set('session-a')
$turnStartedAt.set(Date.now())
const { container } = renderIndicator()
expect(container.querySelector('[role="status"]')?.hasAttribute('data-conversation-scaffold')).toBe(true)
})
})
@@ -0,0 +1,387 @@
import { useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type FC, type ReactNode, useEffect, useMemo, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { activitySignature, toolNarratesWait, TURN_QUIET_S } from '@/components/assistant-ui/thread/turn-activity'
import { toolPresentVerb } from '@/components/assistant-ui/tool/run-summary'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { SCAFFOLD_LABEL_CLASS } from '@/components/chat/scaffold-row'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { StatusPulse } from '@/components/ui/status-pulse'
import { getLocalModelsStatus } from '@/hermes'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { $backgroundResume } from '@/store/background-delegation'
import { sessionCompacting } from '@/store/compaction'
import { $localModelsEnabled } from '@/store/local-models-flag'
import { sessionAwaitingInput } from '@/store/prompts'
import { parseModelLoadWait, sessionProviderWait } from '@/store/provider-wait'
import { $currentModel } from '@/store/session'
import { type DraftingTool, sessionDraftingTool } from '@/store/tool-drafting'
import type { LocalModelLoadProgress } from '@/types/hermes'
// A status line is scaffolding like any other — "Editing" while the model
// drafts a call is the same kind of line as "Explored 3 files" once it has run,
// and reads as one continuous column only if it shares their type and colour.
const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({
children,
label,
className,
...rest
}) => (
<div
aria-label={label}
aria-live="polite"
className={cn(
'flex min-w-0 max-w-full items-center gap-1.5 self-start leading-(--conversation-line-height)',
'text-(--conversation-scaffold-text)',
className
)}
data-conversation-scaffold=""
role="status"
{...rest}
>
{children}
</div>
)
// Fixed label while auto-compaction runs — decoupled from backend status text.
const COMPACTION_LABEL = 'Summarizing thread'
const HintText: FC<{ children: ReactNode }> = ({ children }) => (
<span className={cn(SCAFFOLD_LABEL_CLASS, 'shimmer min-w-0 flex-1 truncate')}>{children}</span>
)
/** Renderer-side load synthesis: poll the local-models status while a turn
* is busy with NO progress frame from the backend. The backend's wait loop
* only narrates the MAIN chat request — a model load triggered while the
* gateway is still initializing, or one consumed by a parallel auxiliary
* call (title generation autoloads the same model), never gets a frame,
* and the load looked like nothing was happening. The status route reads
* the same SSE snapshot, so this bar carries the identical percent. */
function useLocalModelLoad(active: boolean): (LocalModelLoadProgress & { model: string }) | null {
const model = useStore($currentModel)
const [progress, setProgress] = useState<(LocalModelLoadProgress & { model: string }) | null>(null)
// Behind the --local launch flag: without it, no status polling and no
// load bar (the local server can't be the current provider anyway).
const enabled = $localModelsEnabled.get()
useEffect(() => {
if (!enabled || !active || !model) {
setProgress(null)
return
}
let cancelled = false
let timer: number | undefined
const tick = async () => {
try {
const status = await getLocalModelsStatus()
const entry = status.loading?.[model]
if (!cancelled) {
setProgress(entry ? { ...entry, model } : null)
}
} catch {
if (!cancelled) {
setProgress(null)
}
}
if (!cancelled) {
timer = window.setTimeout(() => void tick(), 1_500)
}
}
void tick()
return () => {
cancelled = true
if (timer !== undefined) {
window.clearTimeout(timer)
}
}
}, [enabled, active, model])
return progress
}
/** Wait hint with a real progress bar for managed-local model loads and
* prompt processing. The percents come from llama-server itself (per-tensor
* load callback / live prefill counter, via the gateway's wait frames), so a
* determinate bar is honest — a 40s cold load or a long prefill reads as
* visible progress instead of an alarming stall. */
const WaitHint: FC<{ hint: string }> = ({ hint }) => {
const { t } = useI18n()
const load = parseModelLoadWait(hint)
if (!load) {
return <HintText>{hint}</HintText>
}
const label =
load.kind === 'load' ? t.assistant.thread.loadingLocalModel(load.model) : t.assistant.thread.processingPrompt
return <ProgressHint label={label} percent={load.percent} />
}
const ProgressHint: FC<{ label: string; percent: null | number }> = ({ label, percent }) => (
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className={cn(SCAFFOLD_LABEL_CLASS, 'shimmer min-w-0 shrink truncate')}>{label}</span>
{percent !== null && (
<>
<span className="h-1 w-24 shrink-0 overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
<span
className="block h-full rounded-full bg-primary transition-[width] duration-500"
style={{ width: `${Math.max(2, percent)}%` }}
/>
</span>
<span className={cn(SCAFFOLD_LABEL_CLASS, 'shrink-0 tabular-nums')}>{percent}%</span>
</>
)}
</span>
)
/** These indicators render inside whichever transcript mounted them, so every
* session-scoped signal comes from that surface's view — a tile must never
* show the primary chat's compaction, prompt-wait, or turn timer. */
function useThreadSessionStatus() {
const view = useSessionView()
const sessionId = useStore(view.$runtimeId)
// The same turn-busy the composer's arc border and Stop button read. The
// message-level `running` flag is a weaker signal: it goes false in the gaps
// between bubbles (a sealed interim row, a settled turn the backend hasn't
// finished with), which is exactly when the transcript used to fall silent
// while the app still said it was working.
const busy = useStore(view.$busy)
const turnStartedAt = useStore(view.$turnStartedAt)
const compacting = useStore(useMemo(() => sessionCompacting(sessionId), [sessionId]))
const drafting = useStore(useMemo(() => sessionDraftingTool(sessionId), [sessionId]))
const providerWait = useStore(useMemo(() => sessionProviderWait(sessionId), [sessionId]))
// A pending clarify / approval / sudo / secret means the turn is paused on the
// user, not working — so don't resurrect the "thinking" timer while they
// decide (matches the pet's awaitingInput pose taking priority over busy).
const awaitingInput = useStore(useMemo(() => sessionAwaitingInput(sessionId), [sessionId]))
return {
awaitingInput,
busy,
compacting,
drafting,
providerWait,
// Epoch ms this surface's turn began, or undefined between turns. The
// origin for anything measuring the WHOLE turn rather than one phase of
// it — including the first seconds of a brand-new chat, where the value is
// seeded at submit and there is no runtime session to key off yet.
turnStartedAt: turnStartedAt ?? undefined
}
}
// Long enough that a tool whose arguments arrive in a few frames never gets to
// strobe a label, short enough that a real wait is named almost immediately.
const DRAFTING_REVEAL_MS = 200
/**
* What to call the wait, if it deserves a name. Compaction outranks a draft —
* it's rarer, slower, and explains a transcript that looks like it reset.
*/
function useStatusHint(compacting: boolean, drafting: DraftingTool | null, providerWait: string): string {
const [revealed, setRevealed] = useState(false)
const name = drafting?.name ?? ''
useEffect(() => {
setRevealed(false)
if (!name) {
return
}
const id = window.setTimeout(() => setRevealed(true), DRAFTING_REVEAL_MS)
return () => window.clearTimeout(id)
}, [name])
if (compacting) {
return COMPACTION_LABEL
}
if (providerWait) {
return providerWait
}
return revealed && name ? toolPresentVerb(name) : ''
}
export const CenteredThreadSpinner: FC = () => {
const { t } = useI18n()
return (
<div
aria-label={t.assistant.thread.loadingSession}
className="pointer-events-none absolute inset-0 z-1 grid place-items-center"
role="status"
>
<Loader
aria-hidden="true"
className="size-12 text-midground/70"
pathSteps={220}
role="presentation"
strokeScale={0.72}
type="rose-curve"
/>
</div>
)
}
export const ResponseLoadingIndicator: FC = () => {
const { t } = useI18n()
const { compacting, drafting, providerWait, turnStartedAt } = useThreadSessionStatus()
const elapsed = useElapsedSeconds(true, undefined, turnStartedAt)
const hint = useStatusHint(compacting, drafting, providerWait)
// Renderer-synthesized load bar: covers loads the backend's wait loop
// can't narrate (gateway still initializing, or an auxiliary call — not
// the main request — triggered the autoload). A real wait frame wins.
const localLoad = useLocalModelLoad(!hint)
return (
<StatusRow data-slot="aui_response-loading" label={hint || t.assistant.thread.loadingResponse}>
<StatusPulse
aria-hidden="true"
className="dither inline-block size-3 rounded-[2px] text-midground/80"
kind="opacity"
/>
{hint ? (
<WaitHint hint={hint} />
) : localLoad ? (
<ProgressHint label={t.assistant.thread.loadingLocalModel(localLoad.model)} percent={localLoad.percent} />
) : null}
<ActivityTimerText seconds={elapsed} />
</StatusRow>
)
}
// Parked-background affordance: a top-level delegate_task runs in the
// background, so the parent turn ends and the app goes idle while the subagent
// keeps working and its result re-enters as a fresh turn later. Instead of a
// spinner (reads as "stuck"), reuse the same compact, centered system-note
// chrome as the steer / slash-status lines (SystemMessage above) so it sits in
// the thread like every other meta line. Idle-only (gated upstream). Null when
// nothing is parked.
export const BackgroundResumeNotice: FC = () => {
const { t } = useI18n()
const resume = useStore($backgroundResume)
if (!resume) {
return null
}
const label = resume.activity ?? t.assistant.thread.resumeWhenBackgroundDone(resume.count)
return (
<div
aria-live="polite"
className="flex max-w-[min(86%,44rem)] items-center gap-1.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/55"
data-slot="aui_background-resume"
role="status"
>
<Codicon className="text-muted-foreground/55" name="sync" size="0.75rem" />
<span className="shimmer min-w-0 truncate">{label}</span>
</div>
)
}
// Tail activity row. The pre-first-token spinner goes away once content flows,
// but a turn keeps working through gaps it produces nothing during — between
// one tool result landing and the next call arriving, while the provider
// thinks, while a sealed bubble waits on the next one. The composer's arc
// border and Stop button are lit through all of it; the transcript used to be
// silent for most of it, and those seconds went uncounted.
//
// So this row follows the SAME busy signal the composer does, and times every
// gap from the moment the turn last showed something rather than from its own
// mount. What it doesn't do is double-narrate: a tool call in flight already
// carries its own row and timer.
//
// Subscribes to the activity signal ITSELF (rather than taking it as a prop)
// so that per-token updates re-render only this leaf, not the whole
// AssistantMessage subtree.
export const TurnActivityIndicator: FC = () => {
const { t } = useI18n()
const activity = useAuiState(s => activitySignature(s.message.content))
// Timestamp of the last visible progress, held from the moment the quiet
// spell qualifies. Holding the timestamp (not a boolean) is what lets the
// timer read "quiet for 12s" rather than the age of this component, which is
// the whole turn so far.
const [quietSince, setQuietSince] = useState<number | undefined>(undefined)
const { awaitingInput, busy, compacting, drafting, providerWait, turnStartedAt } = useThreadSessionStatus()
const hint = useStatusHint(compacting, drafting, providerWait)
// A tool run at the tail already narrates the wait — its summary counts the
// calls, its ticker names the current one, and it carries its own timer. A
// second spinner under that adds a line and says nothing new. Silent tools
// (`todo`, reactions) render nothing, so they narrate nothing.
const toolNarrating = useAuiState(s => toolNarratesWait(s.message.content))
// Streaming counts as working too, and it leads busy by a flush on the first
// turn of a fresh chat — so the row can't wait for the store to catch up.
const messageRunning = useAuiState(s => s.message.status?.type === 'running')
// Renderer-synthesized load bar (see ResponseLoadingIndicator).
const working = busy || messageRunning
const localLoad = useLocalModelLoad(working && !hint && !toolNarrating)
useEffect(() => {
setQuietSince(undefined)
const seenAt = Date.now()
const id = window.setTimeout(() => setQuietSince(seenAt), TURN_QUIET_S * 1000)
return () => window.clearTimeout(id)
}, [activity])
// Every second the app claims to be working belongs to something. A named
// wait says what it is straight away; an unnamed gap has to go quiet for
// TURN_QUIET_S first, or a run of quick calls would strobe a row between
// each one. The two exemptions are waits already accounted for elsewhere: a
// question the user is answering, and a tool call carrying its own timer.
// A live local-model load is a named wait too — it must not wait out the
// quiet window (the load IS the story from second one).
const active =
working && !awaitingInput && !toolNarrating && (Boolean(hint) || localLoad !== null || quietSince !== undefined)
// Compaction owns the whole turn, so it keeps counting from the turn's start;
// anything else counts from the moment the turn last produced something — the
// gap's own mark, or the draft's, whichever named the wait first.
const elapsed = useElapsedSeconds(
active,
undefined,
compacting ? turnStartedAt : (quietSince ?? drafting?.since ?? turnStartedAt)
)
if (!active) {
return null
}
return (
<StatusRow data-slot="aui_turn-activity" label={hint || 'Hermes is working'}>
<StatusPulse
aria-hidden="true"
className="dither inline-block size-3 rounded-[2px] text-midground/80"
kind="opacity"
/>
{hint ? (
<WaitHint hint={hint} />
) : localLoad ? (
<ProgressHint label={t.assistant.thread.loadingLocalModel(localLoad.model)} percent={localLoad.percent} />
) : null}
<ActivityTimerText seconds={elapsed} />
</StatusRow>
)
}
@@ -0,0 +1,793 @@
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { useEffect, useState } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { $reasoningCollapsedByDefault } from '@/store/reasoning-disclosure'
import { stubThreadEnvironment, stubThreadViewportSize, ThreadRuntime } from '../test-utils'
import { Thread } from '.'
const createdAt = new Date('2026-05-01T00:00:00.000Z')
const resizeObservers = new Set<TestResizeObserver>()
class TestResizeObserver {
private target: Element | null = null
constructor(private readonly callback: ResizeObserverCallback) {
resizeObservers.add(this)
}
observe(target: Element) {
this.target = target
}
unobserve() {}
disconnect() {
resizeObservers.delete(this)
}
trigger(height: number) {
if (!this.target) {
return
}
this.callback(
[
{
contentRect: { height } as DOMRectReadOnly,
target: this.target
} as ResizeObserverEntry
],
this as unknown as ResizeObserver
)
}
}
stubThreadEnvironment()
// This suite drives the virtualizer, so it needs an observer that reports.
vi.stubGlobal('ResizeObserver', TestResizeObserver)
stubThreadViewportSize()
async function wait(ms: number) {
await act(async () => {
await new Promise(resolve => window.setTimeout(resolve, ms))
})
}
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'Stream a response' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistantMessage(text: string, running = true): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text }],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantErrorMessage(error: string): ThreadMessage {
return {
id: 'assistant-error-1',
role: 'assistant',
content: [],
status: { type: 'incomplete', reason: 'error', error },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantReasoningMessage(text: string, running = false): ThreadMessage {
return {
id: 'assistant-reasoning-1',
role: 'assistant',
content: [{ type: 'reasoning', text }],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantMultiReasoningMessage(texts: string[]): ThreadMessage {
return {
id: 'assistant-reasoning-multi-1',
role: 'assistant',
content: texts.map(text => ({ type: 'reasoning', text })),
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantSeparatedReasoningMessage(): ThreadMessage {
return {
id: 'assistant-reasoning-separated-1',
role: 'assistant',
content: [
{ type: 'reasoning', text: ' Complete first thought.', status: { type: 'complete' } },
{ type: 'text', text: 'Interim answer.' },
{ type: 'reasoning', text: ' Streaming second thought.', status: { type: 'running' } }
],
status: { type: 'running' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantTodoMessage(
todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }>,
running = true
): ThreadMessage {
const suffix = todos.map(todo => `${todo.id}:${todo.status}`).join('|') || 'empty'
return {
id: `assistant-todo-${running ? 'running' : 'done'}-${suffix}`,
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'todo-1',
toolName: 'todo',
args: { todos },
argsText: JSON.stringify({ todos }),
...(running ? {} : { result: { todos } })
}
],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantImageMessage(
running = false,
result: unknown = { image: 'https://cdn.example/cat.png', success: true }
): ThreadMessage {
return {
id: `assistant-image-${running ? 'running' : 'done'}`,
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'image-1',
toolName: 'image_generate',
args: { prompt: 'draw a cat' },
argsText: JSON.stringify({ prompt: 'draw a cat' }),
...(running ? {} : { result })
}
],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantTerminalMessage(): ThreadMessage {
return {
id: 'assistant-terminal-1',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'terminal-1',
toolName: 'terminal',
args: { command: 'npm run check --workspace=apps/desktop' },
argsText: JSON.stringify({ command: 'npm run check --workspace=apps/desktop' }),
result: { exit_code: 0, stdout: 'all checks passed' }
}
],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
interface StreamingControls {
emitSecond: () => void
complete: () => void
}
function StreamingHarness({ onControls }: { onControls?: (controls: StreamingControls) => void } = {}) {
const [messages, setMessages] = useState<ThreadMessage[]>([userMessage()])
const [isRunning, setIsRunning] = useState(true)
useEffect(() => {
const first = window.setTimeout(() => {
setMessages([userMessage(), assistantMessage('first chunk')])
}, 50)
if (onControls) {
onControls({
emitSecond: () => {
setMessages([userMessage(), assistantMessage('first chunk second chunk')])
},
complete: () => {
setMessages([userMessage(), assistantMessage('first chunk second chunk', false)])
setIsRunning(false)
}
})
return () => window.clearTimeout(first)
}
const second = window.setTimeout(() => {
setMessages([userMessage(), assistantMessage('first chunk second chunk')])
}, 500)
const complete = window.setTimeout(() => {
setMessages([userMessage(), assistantMessage('first chunk second chunk', false)])
setIsRunning(false)
}, 700)
return () => {
window.clearTimeout(first)
window.clearTimeout(second)
window.clearTimeout(complete)
}
}, [onControls])
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages,
isRunning,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread loading={isRunning && messages.at(-1)?.role !== 'assistant' ? 'response' : undefined} />
</AssistantRuntimeProvider>
)
}
const TodoHarness = ({ message }: { message: ThreadMessage }) => (
<ThreadRuntime messages={[message]}>
<Thread />
</ThreadRuntime>
)
function MessageHarness({ message }: { message: ThreadMessage }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function TranscriptHarness({ messages }: { messages: ThreadMessage[] }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages,
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
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<ThreadMessage>({
messages: [message],
isRunning: true,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function ReasoningHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantReasoningMessage(' The user is asking what this file is.')],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function RunningReasoningHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantReasoningMessage('```ts\nconst answer = 42\n', true)],
isRunning: true,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
// A turn that streams reasoning and then settles — the transition the
// preview latch exists for. `settle()` flips the thread to not-running.
function renderSettlingReasoning() {
let setRunning: ((running: boolean) => void) | undefined
function SettlingReasoningHarness() {
const [running, setRunningState] = useState(true)
setRunning = setRunningState
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantReasoningMessage('The user asked a question.', running)],
isRunning: running,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
const { container } = render(<SettlingReasoningHarness />)
return { container, settle: () => act(() => setRunning?.(false)) }
}
function GroupedReasoningHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantMultiReasoningMessage([' First thought.', ' Second thought.'])],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function IntroHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread intro={{ personality: 'default', seed: 1 }} />
</AssistantRuntimeProvider>
)
}
function DismissibleErrorHarness({ onDismissError }: { onDismissError: (messageId: string) => void }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantErrorMessage('OpenRouter rejected the request (403).')],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread onDismissError={onDismissError} />
</AssistantRuntimeProvider>
)
}
describe('assistant-ui streaming renderer', () => {
beforeEach(() => {
resizeObservers.clear()
$reasoningCollapsedByDefault.set(false)
})
it('renders assistant text incrementally before completion', async () => {
let controls: StreamingControls | undefined
const registerControls = (next: StreamingControls) => {
controls = next
}
const { container } = render(<StreamingHarness onControls={registerControls} />)
expect(screen.getByRole('status', { name: 'Hermes is loading a response' })).toBeTruthy()
await waitFor(() => {
expect(container.textContent).toContain('first chunk')
})
expect(container.textContent).not.toContain('second chunk')
expect(screen.queryByRole('status', { name: 'Hermes is loading a response' })).toBeNull()
// Producer-gated, not wall-clock-gated: the old test slept 80ms and
// assumed a 500ms timer could not fire before the assertion. On a loaded
// runner the test thread could be descheduled for >500ms, so both chunks
// arrived and this clean behavior test flaked.
act(() => controls?.emitSecond())
await waitFor(() => {
expect(container.textContent).toContain('first chunk second chunk')
})
act(() => controls?.complete())
await waitFor(() => {
expect(container.textContent).toContain('first chunk second chunk')
})
})
it('does not render composer clearance for intro-only threads', () => {
const { container } = render(<IntroHarness />)
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(
<TranscriptHarness
messages={[
userMessage(),
assistantInterimMessage('Let me check the files.'),
assistantInterimMessage('Now applying the patch.', 'assistant-interim-2'),
assistantMessage('All done — patch applied.', false)
]}
/>
)
// 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('puts the turn duration on the action bar row instead of a line of its own', () => {
const settled = {
...assistantMessage('All done.', false),
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: { durationS: 12 }
}
} as ThreadMessage
const { container } = render(<TranscriptHarness messages={[userMessage(), settled]} />)
const duration = container.querySelector('[data-slot="aui_turn-duration"]')
const actions = container.querySelector('[data-slot="aui_msg-actions"]')
// Same row as the (always-mounted) action bar: the footer's height is
// already reserved while the turn streams, so landing the duration there
// adds no height when the turn settles.
expect(duration).toBeTruthy()
expect(duration?.parentElement).toBe(actions?.parentElement)
})
it('renders assistant provider errors inline', () => {
render(<MessageHarness message={assistantErrorMessage('OpenRouter rejected the request (403).')} />)
expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).')
})
it('omits the dismiss control when no onDismissError handler is supplied', () => {
render(<MessageHarness message={assistantErrorMessage('OpenRouter rejected the request (403).')} />)
expect(screen.queryByRole('button', { name: 'Dismiss error' })).toBeNull()
})
it('invokes onDismissError with the errored message id when the dismiss control is clicked', () => {
const onDismissError = vi.fn()
render(<DismissibleErrorHarness onDismissError={onDismissError} />)
const dismiss = screen.getByRole('button', { name: 'Dismiss error' })
fireEvent.click(dismiss)
expect(onDismissError).toHaveBeenCalledTimes(1)
expect(onDismissError).toHaveBeenCalledWith('assistant-error-1')
})
// Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned
// by the use-stick-to-bottom library and covered by its own test suite. We
// don't re-assert its scrollTop mechanics here — doing so in jsdom (no real
// layout, spring animation via rAF) only produces brittle change-detector
// tests. The rendering/streaming-content tests below remain the contract.
it('renders an incomplete streaming fenced code block as a code card', async () => {
const { container } = render(<RunningMessageHarness message={assistantMessage('```ts\nconst answer = 42\n')} />)
await waitFor(() => {
expect(container.querySelector('[data-slot="code-card"]')).toBeTruthy()
})
expect(container.textContent).toContain('const answer = 42')
expect(container.textContent).not.toContain('```ts')
})
it('renders an incomplete streaming reasoning fenced code block as a code card', async () => {
const { container } = render(<RunningReasoningHarness />)
const ui = within(container)
const thinkingToggle = ui.getByRole('button', { name: /thinking/i })
if (thinkingToggle.getAttribute('aria-expanded') !== 'true') {
fireEvent.click(thinkingToggle)
}
await waitFor(() => {
expect(container.querySelector('[data-slot="code-card"]')).toBeTruthy()
})
await waitFor(() => {
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toContain('const answer = 42')
})
expect(container.textContent).not.toContain('```ts')
})
it('keeps the height-capped thinking preview scrollable after the turn settles', async () => {
const { container, settle } = renderSettlingReasoning()
const live = container.querySelector('[data-slot="aui_thinking-body"]')?.className ?? ''
expect(live).toContain('max-h-40')
expect(live).toMatch(/\boverflow-auto\b/)
expect(live).not.toMatch(/\boverflow-hidden\b/)
settle()
await waitFor(() => {
expect(within(container).getByRole('button', { name: /thought/i })).toBeTruthy()
})
const settled = container.querySelector('[data-slot="aui_thinking-body"]')?.className ?? ''
expect(settled).toContain('max-h-40')
expect(settled).toMatch(/\boverflow-auto\b/)
expect(settled).not.toMatch(/\boverflow-hidden\b/)
})
it('does not collapse a live thinking preview when the turn settles', async () => {
const { container, settle } = renderSettlingReasoning()
const toggle = within(container).getByRole('button', { name: /thinking/i })
expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(container.querySelector('[data-slot="aui_reasoning-text"]')).toBeTruthy()
settle()
await waitFor(() => {
expect(
within(container)
.getByRole('button', { name: /thought/i })
.getAttribute('aria-expanded')
).toBe('true')
})
expect(container.querySelector('[data-slot="aui_reasoning-text"]')).toBeTruthy()
})
it('leaves a settling turn collapsed when the collapsed-by-default preference is enabled', async () => {
$reasoningCollapsedByDefault.set(true)
const { container, settle } = renderSettlingReasoning()
expect(
within(container)
.getByRole('button', { name: /thinking/i })
.getAttribute('aria-expanded')
).toBe('false')
settle()
await waitFor(() => {
expect(
within(container)
.getByRole('button', { name: /thought/i })
.getAttribute('aria-expanded')
).toBe('false')
})
expect(container.querySelector('[data-slot="aui_reasoning-text"]')).toBeNull()
})
it('keeps streaming reasoning collapsed by default when the preference is enabled', () => {
$reasoningCollapsedByDefault.set(true)
const { container } = render(<RunningReasoningHarness />)
const thinkingToggle = within(container).getByRole('button', { name: /thinking/i })
expect(thinkingToggle.getAttribute('aria-expanded')).toBe('false')
expect(container.querySelector('[data-slot="aui_reasoning-text"]')).toBeNull()
fireEvent.click(thinkingToggle)
expect(thinkingToggle.getAttribute('aria-expanded')).toBe('true')
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toContain('const answer = 42')
})
it('renders reasoning text without a leading token space', () => {
const { container } = render(<ReasoningHarness />)
const ui = within(container)
// Settled, so the header is past tense — a running block says "Thinking".
fireEvent.click(ui.getByRole('button', { name: /thought/i }))
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toBe(
'The user is asking what this file is.'
)
})
it('groups consecutive reasoning parts under one thinking disclosure', () => {
const { container } = render(<GroupedReasoningHarness />)
const disclosures = container.querySelectorAll('[data-slot="aui_thinking-disclosure"]')
expect(disclosures.length).toBe(1)
fireEvent.click(disclosures[0].querySelector('button')!)
const reasoningParts = container.querySelectorAll('[data-slot="aui_reasoning-text"]')
expect(reasoningParts.length).toBe(2)
expect(reasoningParts[0]?.textContent).toBe('First thought.')
expect(reasoningParts[1]?.textContent).toBe('Second thought.')
})
it('does not reopen an earlier completed thinking group when a later group is running', () => {
const { container } = render(<RunningMessageHarness message={assistantSeparatedReasoningMessage()} />)
const disclosures = container.querySelectorAll('[data-slot="aui_thinking-disclosure"]')
expect(disclosures.length).toBe(2)
expect(disclosures[0].querySelector('button')?.getAttribute('aria-expanded')).toBe('false')
expect(disclosures[1].querySelector('button')?.getAttribute('aria-expanded')).toBe('true')
expect(container.textContent).not.toContain('Complete first thought.')
expect(container.textContent).toContain('Interim answer.')
})
it('does not render an inline todo panel — todos live in the composer status stack', () => {
const { container } = render(
<TodoHarness
message={assistantTodoMessage([
{ content: 'Gather ingredients', id: 'prep', status: 'completed' },
{ content: 'Boil water', id: 'boil', status: 'in_progress' }
])}
/>
)
expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeNull()
})
it('renders completed image generation results in the tool slot', async () => {
const { container } = render(<MessageHarness message={assistantImageMessage()} />)
await waitFor(() => {
expect(screen.getByRole('img', { name: 'Generated image' }).getAttribute('src')).toBe(
'https://cdn.example/cat.png'
)
})
expect(container.querySelector('[data-slot="aui_generated-image"]')).toBeTruthy()
expect(screen.queryByRole('status', { name: /rendering image/i })).toBeNull()
})
it('uses the normal tool row for failed image generations instead of dropping their error payload', async () => {
const { container } = render(
<MessageHarness
message={assistantImageMessage(false, { error: 'FAL rejected the prompt', image: null, success: false })}
/>
)
fireEvent.click(container.querySelector('[data-tool-row] button')!)
await waitFor(() => {
expect(container.textContent).toContain('FAL rejected the prompt')
})
expect(container.querySelector('[data-slot="aui_generated-image"]')).toBeNull()
expect(container.textContent).not.toContain('"success":false')
})
it('shows the command prompt and exit code for terminal calls', async () => {
const { container } = render(<MessageHarness message={assistantTerminalMessage()} />)
fireEvent.click(container.querySelector('[data-tool-row] button')!)
await waitFor(() => {
expect(container.textContent).toContain('$ npm run check --workspace=apps/desktop')
expect(container.textContent).toContain('exit 0')
expect(container.textContent).toContain('all checks passed')
})
})
})
@@ -0,0 +1,67 @@
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { $displayTimestamps } from '@/store/display-timestamps'
import { stubThreadEnvironment } from '../test-utils'
import { Thread } from '.'
// Timeline timestamps render only when `display.timestamps` is enabled.
$displayTimestamps.set(true)
const timestamp = new Date('2026-05-01T00:00:00.000Z')
stubThreadEnvironment()
function Harness({ text }: { text: string }) {
const message = {
id: 'system-1',
role: 'system',
content: [{ type: 'text', text }],
createdAt: timestamp,
metadata: { custom: { timelineTimestamp: timestamp.getTime() / 1000 } }
} as unknown as ThreadMessage
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function expectTimestampSeparated(container: HTMLElement, precedingText: string) {
const row = container.querySelector('[data-role="system"]')
const stamp = row?.querySelector('[data-slot="timeline-timestamp"]')?.textContent
expect(stamp).toBeTruthy()
expect(row?.textContent).toContain(`${precedingText} ${stamp}`)
}
afterEach(cleanup)
describe('system message timestamp text separation', () => {
it('separates an ordinary system row timestamp in accessible and copied text', () => {
const { container } = render(<Harness text="Review saved." />)
expectTimestampSeparated(container, 'Review saved.')
})
it('separates a slash-status timestamp in accessible and copied text', () => {
const { container } = render(<Harness text={'slash:/model\nmodel changed'} />)
expectTimestampSeparated(container, 'model changed')
})
it('separates a steer timestamp in accessible and copied text', () => {
const { container } = render(<Harness text="steer:rerun tests" />)
expectTimestampSeparated(container, 'rerun tests')
})
})
@@ -0,0 +1,115 @@
import { MessagePrimitive, useAuiState } from '@assistant-ui/react'
import { type FC } from 'react'
import { messageContentText } from '@/components/assistant-ui/thread/content'
import { MessageTimelineTimestamp } from '@/components/assistant-ui/thread/timeline-timestamp'
import { SCAFFOLD_LABEL_CLASS } from '@/components/chat/scaffold-row'
import { Codicon } from '@/components/ui/codicon'
import { ToolIcon } from '@/components/ui/tool-icon'
import { LinkifiedText } from '@/lib/external-link'
import { cn } from '@/lib/utils'
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]*)$/
export const SystemMessage: FC = () => {
const text = useAuiState(s => messageContentText(s.message.content))
if (!text) {
return null
}
// The self-improvement review saved something to memory/skills — the same
// kind of event as a landed `memory` write, so it wears the same chrome:
// brain glyph with the gold→purple glow, gradient label, purple detail,
// left-aligned in the reading column like every other scaffold line.
const reviewNote = text.match(REVIEW_NOTE_RE)
if (reviewNote?.groups) {
const detail = reviewNote.groups.detail.trim()
return (
<MessagePrimitive.Root
className="flex w-full min-w-0 max-w-full items-start gap-1.5 self-start py-0.5"
data-role="system"
data-slot="aui_system-message-root"
>
<span className="tool-memory-legendary-glyph flex h-(--conversation-line-height) w-3.5 shrink-0 items-center justify-center">
<ToolIcon className="text-(--tool-memory-legendary-icon)" name="brain" size="0.875rem" />
</span>
<span className={cn(SCAFFOLD_LABEL_CLASS, 'tool-memory-legendary-title shrink-0 text-transparent')}>
{reviewNote.groups.label.trim()}
</span>
{detail && (
<span className={cn(SCAFFOLD_LABEL_CLASS, 'tool-memory-legendary-meta min-w-0 wrap-anywhere')}>{detail}</span>
)}
</MessagePrimitive.Root>
)
}
const steerNote = text.match(STEER_NOTE_RE)
if (steerNote?.groups) {
return (
<MessagePrimitive.Root
className="flex max-w-[min(86%,44rem)] items-center gap-1.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60"
data-role="system"
data-slot="aui_system-message-root"
>
<Codicon className="text-muted-foreground/55" name="compass" size="0.75rem" />
<span className="text-muted-foreground/55">steered</span>
<span className="text-muted-foreground/35">·</span>
<span className="whitespace-pre-wrap">{steerNote.groups.text.trim()}</span> <MessageTimelineTimestamp />
</MessagePrimitive.Root>
)
}
const slashStatus = text.match(SLASH_STATUS_RE)
if (slashStatus?.groups) {
const output = slashStatus.groups.output.trim()
// Single-line status (e.g. "model → x") reads best centered inline; padded
// multiline output (catalogs, usage tables) needs left-aligned, wider room
// or the column alignment breaks.
const multiline = output.includes('\n')
return (
<MessagePrimitive.Root
className={cn(
'w-[60%] max-w-[44rem] self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60',
multiline ? 'text-left' : 'text-center'
)}
data-role="system"
data-slot="aui_system-message-root"
>
<span className="font-mono text-muted-foreground/55">{slashStatus.groups.command}</span>
{multiline ? (
<LinkifiedText className="mt-0.5 block whitespace-pre-wrap" explicitOnly pretty={false} text={output} />
) : (
<>
<span className="mx-1.5 text-muted-foreground/35">·</span>
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={output} />
</>
)}{' '}
<MessageTimelineTimestamp className={cn(multiline ? 'mt-0.5 block' : 'ml-1.5')} />
</MessagePrimitive.Root>
)
}
const multiline = text.includes('\n')
return (
<MessagePrimitive.Root
className={cn(
'w-[60%] max-w-[44rem] self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/55',
multiline ? 'text-left' : 'text-center'
)}
data-role="system"
data-slot="aui_system-message-root"
>
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={text} />{' '}
<MessageTimelineTimestamp className={cn(multiline ? 'mt-0.5 block' : 'ml-1.5')} />
</MessagePrimitive.Root>
)
}
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import { activeTimelineIndex, deriveTimelineEntries, sameTimelineEntries, timelinePreview } from './timeline-data'
describe('timelinePreview', () => {
it('collapses whitespace to a single line', () => {
expect(timelinePreview('hello\n\n world\tagain')).toBe('hello world again')
})
it('truncates with an ellipsis past the limit', () => {
const out = timelinePreview('abcdefghij', 5)
expect(out).toBe('abcd…')
expect(out.length).toBe(5)
})
})
describe('deriveTimelineEntries', () => {
it('keeps non-empty user prompts in order', () => {
expect(
deriveTimelineEntries([
{ id: 'u1', role: 'user', text: 'first' },
{ id: 'a1', role: 'assistant', text: 'answer' },
{ id: 'u2', role: 'user', text: ' second ' }
])
).toEqual([
{ id: 'u1', preview: 'first' },
{ id: 'u2', preview: 'second' }
])
})
it('drops blanks and background-process notifications', () => {
expect(
deriveTimelineEntries([
{ id: 'u1', role: 'user', text: ' ' },
{ id: 'u2', role: 'user', text: '[IMPORTANT: Background process 123 finished]' },
{ id: 'u3', role: 'user', text: 'real prompt' }
]).map(e => e.id)
).toEqual(['u3'])
})
})
describe('sameTimelineEntries', () => {
const rail = [
{ id: 'u1', preview: 'first' },
{ id: 'u2', preview: 'second' }
]
it('treats an identical derivation as unchanged, so the memo can reuse it', () => {
expect(sameTimelineEntries(rail, [...rail.map(e => ({ ...e }))])).toBe(true)
})
it('detects a changed preview, id, or length', () => {
expect(sameTimelineEntries(rail, [rail[0], { id: 'u2', preview: 'edited' }])).toBe(false)
expect(sameTimelineEntries(rail, [rail[0], { id: 'u9', preview: 'second' }])).toBe(false)
expect(sameTimelineEntries(rail, [rail[0]])).toBe(false)
})
it('is stable when a filtered-out prompt joins the transcript', () => {
const withNoise = deriveTimelineEntries([
{ id: 'u1', role: 'user', text: 'first' },
{ id: 'u2', role: 'user', text: 'second' },
{ id: 'u3', role: 'user', text: '[IMPORTANT: Background process 7 finished]' }
])
expect(sameTimelineEntries(rail, withNoise)).toBe(true)
})
})
describe('activeTimelineIndex', () => {
it('returns the last prompt scrolled to or above the top edge', () => {
expect(activeTimelineIndex([-400, -10, 320])).toBe(1)
})
it('falls back to the first rendered entry', () => {
expect(activeTimelineIndex([null, 120, 480])).toBe(1)
expect(activeTimelineIndex([null, null])).toBe(0)
})
})
@@ -0,0 +1,89 @@
// Pure timeline helpers — no React/DOM; tested in thread-timeline-data.test.ts.
export interface TimelineSourceMessage {
id: string
role: string
text: string
}
export interface TimelineEntry {
id: string
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 {
const collapsed = text.replace(/\s+/g, ' ').trim()
if (collapsed.length <= max) {
return collapsed
}
return `${collapsed.slice(0, max - 1).trimEnd()}`
}
export function deriveTimelineEntries(messages: readonly TimelineSourceMessage[]): TimelineEntry[] {
const entries: TimelineEntry[] = []
for (const message of messages) {
if (message.role !== 'user') {
continue
}
const text = message.text.trim()
if (!text || PROCESS_NOTIFICATION_RE.test(text)) {
continue
}
entries.push({ id: message.id, preview: timelinePreview(text) })
}
return entries
}
/** Do two derivations describe the same rail? Lets a rebuild hand back the
* PREVIOUS array so an unchanged transcript costs zero re-renders. */
export function sameTimelineEntries(a: readonly TimelineEntry[], b: readonly TimelineEntry[]): boolean {
if (a === b) {
return true
}
if (a.length !== b.length) {
return false
}
return a.every((entry, index) => entry.id === b[index].id && entry.preview === b[index].preview)
}
/** Last user prompt at/above the viewport top (with slack); else first rendered. */
export function activeTimelineIndex(offsets: readonly (number | null)[], slack: number = 8): number {
let active = -1
let firstRendered = -1
for (let i = 0; i < offsets.length; i++) {
const offset = offsets[i]
if (offset == null) {
continue
}
if (firstRendered === -1) {
firstRendered = i
}
if (offset <= slack) {
active = i
}
}
if (active !== -1) {
return active
}
return firstRendered === -1 ? 0 : firstRendered
}
@@ -0,0 +1,166 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
/**
* The timeline must do NO work it can't currently show. Two gates are proven
* here by rendering the real component and counting the work it performs:
*
* - a background (kept-alive but hidden) tab derives nothing and subscribes
* to nothing — the transcript selector is never even called;
* - an unhovered rail builds its ticks but not the popover's rows.
*
* The prompt-id selector is also asserted to be content-blind, which is what
* keeps a streaming assistant reply from re-deriving previews per token.
*/
interface FakeMessage {
content: unknown
id: string
role: string
}
const selectorCalls = vi.fn()
const transcriptReads = vi.fn()
let messages: FakeMessage[] = []
vi.mock('@assistant-ui/react', () => ({
useAui: () => ({
thread: () => ({
getState: () => {
transcriptReads()
return { messages }
}
})
}),
useAuiState: (selector: (state: { thread: { messages: FakeMessage[] } }) => unknown) => {
selectorCalls()
return selector({ thread: { messages } })
}
}))
let paneActive = true
vi.mock('@/components/pane-shell/pane-visibility', () => ({
usePaneVisible: () => paneActive
}))
vi.mock('@/lib/haptics', () => ({ triggerHaptic: () => {} }))
const { ThreadTimeline } = await import('./timeline')
const userTurn = (id: string, text: string): FakeMessage => ({
content: [{ text, type: 'text' }],
id,
role: 'user'
})
const transcript = (count: number): FakeMessage[] =>
Array.from({ length: count }, (_, i) => userTurn(`u${i}`, `prompt ${i}`))
const renderTimeline = (ui: ReactNode = <ThreadTimeline />) => render(ui)
afterEach(() => {
cleanup()
selectorCalls.mockClear()
transcriptReads.mockClear()
paneActive = true
messages = []
})
describe('ThreadTimeline in a background tab', () => {
it('renders nothing and never reads the transcript', () => {
paneActive = false
messages = transcript(6)
const { container } = renderTimeline()
expect(container.querySelector('[data-slot="thread-timeline"]')).toBeNull()
expect(selectorCalls).not.toHaveBeenCalled()
expect(transcriptReads).not.toHaveBeenCalled()
})
it('renders the rail once its pane becomes the visible tab', () => {
messages = transcript(6)
const { container } = renderTimeline()
expect(container.querySelector('[data-slot="thread-timeline"]')).not.toBeNull()
expect(selectorCalls).toHaveBeenCalled()
})
})
describe('ThreadTimeline popover', () => {
it('builds no rows until the rail is hovered', () => {
messages = transcript(6)
const { container } = renderTimeline()
const popover = container.querySelector('[data-slot="thread-timeline-popover"]')
// The shell renders (it owns the fade transition); its rows do not.
expect(popover).not.toBeNull()
expect(popover?.querySelectorAll('button')).toHaveLength(0)
expect(screen.queryByText('prompt 0')).toBeNull()
})
it('builds the rows on hover and keeps them for the close fade', () => {
messages = transcript(6)
const { container } = renderTimeline()
const rail = container.querySelector<HTMLElement>('[data-slot="thread-timeline"]')!
fireEvent.mouseEnter(rail)
const popover = container.querySelector('[data-slot="thread-timeline-popover"]')
expect(popover?.querySelectorAll('button')).toHaveLength(6)
fireEvent.mouseLeave(rail)
// Still mounted — the popover fades out, it does not pop out of existence.
expect(popover?.querySelectorAll('button')).toHaveLength(6)
})
})
describe('ThreadTimeline below the threshold', () => {
it('renders nothing for a short thread', () => {
messages = transcript(2)
const { container } = renderTimeline()
expect(container.querySelector('[data-slot="thread-timeline"]')).toBeNull()
})
})
describe('ThreadTimeline while a reply streams', () => {
it('does not re-derive the rail as assistant content grows', () => {
messages = [...transcript(6), { content: [{ text: 'th', type: 'text' }], id: 'a1', role: 'assistant' }]
const { rerender } = renderTimeline()
const derivations = transcriptReads.mock.calls.length
// A token lands: the assistant message's content changes, the user prompt
// ids do not — so the memo's change signal is untouched and the previews
// are never rebuilt.
messages = [
...messages.slice(0, -1),
{ content: [{ text: 'thinking…', type: 'text' }], id: 'a1', role: 'assistant' }
]
rerender(<ThreadTimeline />)
expect(transcriptReads.mock.calls.length).toBe(derivations)
})
it('re-derives once a new prompt is sent', () => {
messages = transcript(6)
const { rerender } = renderTimeline()
const derivations = transcriptReads.mock.calls.length
messages = [...messages, userTurn('u6', 'prompt 6')]
rerender(<ThreadTimeline />)
expect(transcriptReads.mock.calls.length).toBeGreaterThan(derivations)
})
})
@@ -0,0 +1,46 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $displayTimestamps, setDisplayTimestampsFromConfig } from '@/store/display-timestamps'
import { TimelineTimestamp } from './timeline-timestamp'
afterEach(cleanup)
beforeEach(() => {
$displayTimestamps.set(false)
})
describe('setDisplayTimestampsFromConfig', () => {
it('accepts boolean and string forms, defaulting off', () => {
setDisplayTimestampsFromConfig(true)
expect($displayTimestamps.get()).toBe(true)
setDisplayTimestampsFromConfig(false)
expect($displayTimestamps.get()).toBe(false)
setDisplayTimestampsFromConfig('true')
expect($displayTimestamps.get()).toBe(true)
setDisplayTimestampsFromConfig(undefined)
expect($displayTimestamps.get()).toBe(false)
})
})
describe('TimelineTimestamp display.timestamps gate', () => {
const timestamp = new Date('2026-05-01T00:00:00.000Z').getTime() / 1000
it('renders nothing while display.timestamps is off (the default)', () => {
const { container } = render(<TimelineTimestamp timestamp={timestamp} />)
expect(container.querySelector('[data-slot="timeline-timestamp"]')).toBeNull()
})
it('renders the stamp once display.timestamps is on', () => {
$displayTimestamps.set(true)
const { container } = render(<TimelineTimestamp timestamp={timestamp} />)
expect(container.querySelector('[data-slot="timeline-timestamp"]')).toBeTruthy()
})
})
@@ -0,0 +1,114 @@
import { useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import type { FC } from 'react'
import { cn } from '@/lib/utils'
import { $displayTimestamps } from '@/store/display-timestamps'
import { formatTimelineRange } from './timestamp'
const preciseDateTime = new Intl.DateTimeFormat(undefined, {
day: 'numeric',
fractionalSecondDigits: 3,
hour: 'numeric',
minute: '2-digit',
month: 'short',
second: '2-digit',
year: 'numeric'
})
const validUnixSeconds = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value) && value > 0
const unixDate = (value: unknown): Date | null => {
if (!validUnixSeconds(value)) {
return null
}
const date = new Date(value * 1000)
return Number.isNaN(date.getTime()) ? null : date
}
export const TimelineTimestamp: FC<{
className?: string
completedAt?: number
timestamp?: number
}> = ({ className, completedAt, timestamp }) => {
// One config key everywhere (#41531): `display.timestamps` in config.yaml
// gates transcript timestamps here exactly as it gates the classic CLI's
// [HH:MM] labels. Display-only, so toggling never touches model context.
const enabled = useStore($displayTimestamps)
const started = unixDate(timestamp)
if (!enabled || !started || !validUnixSeconds(timestamp)) {
return null
}
const completed = validUnixSeconds(completedAt) && completedAt > timestamp ? unixDate(completedAt) : null
const validCompletedAt = completed && validUnixSeconds(completedAt) ? completedAt : undefined
const startLabel = formatTimelineRange(timestamp, undefined)
const completedLabel = validCompletedAt === undefined ? '' : formatTimelineRange(validCompletedAt, undefined)
const title = completed
? `${preciseDateTime.format(started)}${preciseDateTime.format(completed)}`
: preciseDateTime.format(started)
return (
<span
className={cn('text-[0.625rem] leading-4 tabular-nums text-muted-foreground/55', className)}
data-slot="timeline-timestamp"
title={title}
>
<time dateTime={started.toISOString()}>{startLabel}</time>
{completed && validCompletedAt !== undefined && (
<>
{' → '}
<time dateTime={completed.toISOString()}>{completedLabel}</time>
</>
)}
</span>
)
}
/** Timestamp for the current assistant-ui message lifecycle. */
export const MessageTimelineTimestamp: FC<{
className?: string
suppressIfDuplicatePart?: boolean
}> = ({ className, suppressIfDuplicatePart = false }) => {
const timestamp = useAuiState(s => {
const value = (s.message.metadata?.custom as { timelineTimestamp?: unknown } | undefined)?.timelineTimestamp
return validUnixSeconds(value) ? value : undefined
})
const completedAt = useAuiState(s => {
const value = (s.message.metadata?.custom as { timelineCompletedAt?: unknown } | undefined)?.timelineCompletedAt
return validUnixSeconds(value) ? value : undefined
})
const duplicatePart = useAuiState(s => {
const custom = (s.message.metadata?.custom ?? {}) as {
timelineCompletedAt?: unknown
timelineTimestamp?: unknown
}
const solePart =
s.message.parts.length === 1 ? (s.message.parts[0] as { completedAt?: unknown; timestamp?: unknown }) : null
return (
Boolean(solePart) &&
solePart?.timestamp === custom.timelineTimestamp &&
(solePart?.completedAt === custom.timelineCompletedAt ||
(!validUnixSeconds(solePart?.completedAt) && !validUnixSeconds(custom.timelineCompletedAt)))
)
})
if (suppressIfDuplicatePart && duplicatePart) {
return null
}
return <TimelineTimestamp className={className} completedAt={completedAt} timestamp={timestamp} />
}
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it } from 'vitest'
import { ownViewport } from './timeline'
/**
* Several chat surfaces are mounted at once — side by side in a split, and
* stacked as kept-alive inactive tabs. A timeline scrolls its OWN thread.
*/
afterEach(() => {
document.body.innerHTML = ''
})
const surface = (id: string, hidden = false) => `
<div ${hidden ? 'data-pane-hidden' : ''}>
<div data-session-anchor="${id}">
<div data-slot="aui_thread-viewport" id="viewport-${id}"></div>
<div data-slot="thread-timeline" id="timeline-${id}"></div>
</div>
</div>
`
describe('ownViewport', () => {
it('resolves the viewport of the surface the timeline lives in', () => {
document.body.innerHTML = surface('workspace') + surface('session-tile:b')
expect(ownViewport(document.getElementById('timeline-session-tile:b'))?.id).toBe('viewport-session-tile:b')
expect(ownViewport(document.getElementById('timeline-workspace'))?.id).toBe('viewport-workspace')
})
it('ignores a kept-alive tab that matches first', () => {
document.body.innerHTML = surface('workspace', true) + surface('session-tile:b')
expect(ownViewport(document.getElementById('timeline-session-tile:b'))?.id).toBe('viewport-session-tile:b')
})
it('falls back to the document when there is no surface around it', () => {
document.body.innerHTML = '<div data-slot="aui_thread-viewport" id="viewport-lone"></div>'
expect(ownViewport(null)?.id).toBe('viewport-lone')
})
})
@@ -0,0 +1,419 @@
import { useAui, useAuiState } from '@assistant-ui/react'
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import {
activeTimelineIndex,
deriveTimelineEntries,
sameTimelineEntries,
type TimelineEntry,
type TimelineSourceMessage
} from './timeline-data'
const MIN_ENTRIES = 4
const VIEWPORT = '[data-slot="aui_thread-viewport"]'
const HOVER_CLOSE_MS = 140
const ROW_CLASS =
'row-hover relative flex w-full min-w-0 max-w-full select-none overflow-hidden rounded-md px-2 py-1 text-left outline-hidden'
// Surface (border-color/bg/shadow/blur) comes from the shared
// `[data-slot='thread-timeline-popover']` rule in styles.css, so it's 1:1 with
// the dropdown/select/dialog menus. We only own layout + the border/radius here.
const POPOVER_SHELL =
'absolute right-full top-1/2 z-50 max-h-[min(22rem,calc(100vh-8rem))] w-80 max-w-[min(20rem,calc(100vw-2rem))] -translate-y-1/2 overflow-x-hidden overflow-y-auto overscroll-contain rounded-lg border p-1 text-popover-foreground transition-[opacity,transform] duration-100 ease-out group-hover/timeline:transition-none'
function userPromptText(content: unknown): string {
if (typeof content === 'string') {
return content
}
if (!Array.isArray(content)) {
return ''
}
let out = ''
for (const part of content) {
if (typeof part === 'string') {
out += part
continue
}
if (!part || typeof part !== 'object') {
continue
}
const row = part as { text?: unknown; type?: unknown }
if ((!row.type || row.type === 'text') && typeof row.text === 'string') {
out += row.text
}
}
return out
}
/** Index-keyed ref-array setter — `ref={listRef(refs, i)}`. */
const listRef =
<T,>(refs: React.RefObject<(T | null)[]>, index: number) =>
(node: T | null) => {
refs.current[index] = node
}
/** Mouse enter/leave pair forwarding `on` to the shared paint(). */
const hoverProps = (index: number, paint: (index: number, on: boolean) => void) => ({
onMouseEnter: () => paint(index, true),
onMouseLeave: () => paint(index, false)
})
// Constant-duration jump (eased), NOT native `behavior:'smooth'` — Chromium's
// smooth scroll animates proportional to distance, so jumping across a long
// thread crawls for seconds. A fixed ~260ms feels instant near or far. A
// shared rAF handle cancels a prior jump so rapid tick clicks don't fight.
let jumpRaf = 0
function jumpScroll(viewport: HTMLElement, top: number, duration = 170): void {
cancelAnimationFrame(jumpRaf)
const start = viewport.scrollTop
const delta = top - start
if (Math.abs(delta) < 2) {
viewport.scrollTop = top
return
}
const t0 = performance.now()
const ease = (t: number) => 1 - (1 - t) ** 3 // easeOutCubic
const step = (now: number) => {
const p = Math.min(1, (now - t0) / duration)
viewport.scrollTop = start + delta * ease(p)
if (p < 1) {
jumpRaf = requestAnimationFrame(step)
}
}
jumpRaf = requestAnimationFrame(step)
}
// A timeline belongs to ONE chat surface, and several are mounted at once — side
// by side in a split, and stacked (hidden but kept alive) as inactive tabs. Walk
// up to this timeline's own surface before looking for the viewport; a
// document-wide lookup scrolls somebody else's thread.
export const ownViewport = (root: HTMLElement | null): HTMLElement | null =>
(root?.closest('[data-session-anchor]') ?? document).querySelector<HTMLElement>(VIEWPORT)
function scrollToPrompt(root: HTMLElement | null, id: string) {
const viewport = ownViewport(root)
const node = viewport?.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(id)}"]`)
if (!viewport || !node) {
return
}
const top = viewport.scrollTop + (node.getBoundingClientRect().top - viewport.getBoundingClientRect().top) - 8
triggerHaptic('selection')
jumpScroll(viewport, Math.max(0, top))
}
/**
* Right-edge prompt rail — hover previews, click to jump. ≥4 user turns only.
*
* Everything here is DEFERRED until it can actually be seen. A chat surface
* stays mounted while its tab is in the background (keep-alive, see
* pane-visibility.ts), and a background thread keeps streaming, so a naive
* timeline would re-derive previews and re-measure prompt offsets all day for
* a rail nobody is looking at. Four gates, cheapest first:
*
* 1. INACTIVE PANE → render null and subscribe to nothing. The transcript
* selector, the scroll listener, and the popover markup all stand down.
* 2. ACTIVE BUT UNHOVERED → the ticks paint, but the popover's rows are not
* built at all; the previews only exist once the pointer opens it.
* 3. BELOW THE THRESHOLD → the rail renders null, so the measure effect never
* touches layout for it.
* 4. FOLLOWING THE BOTTOM → the active prompt is the last one by definition,
* answered from data instead of a rect walk (see compute() below).
*/
export const ThreadTimeline: FC = () => {
// Cheapest possible gate, and it must come first: an inactive tab returns
// before any of the work below is even declared.
return usePaneVisible() ? <ActiveThreadTimeline /> : null
}
/** Derived prompt rail for a VISIBLE surface. Split out so the hook body — and
* the transcript subscription it opens — never runs for a background tab. */
const ActiveThreadTimeline: FC = () => {
// Cheap in the selector, expensive only when it changes: the ids alone tell
// us whether the RAIL changed. Prompt text is immutable once sent, and an
// edit rewinds the transcript (dropping every id after it) and re-appends a
// fresh message id — so a preview can never go stale behind a stable id.
// Streaming an assistant reply churns that message's content on every token
// and leaves this string untouched, which is the whole point.
const promptIds = useAuiState(s => {
let ids = ''
for (const message of s.thread.messages) {
if (message.role === 'user') {
ids += `${message.id}\n`
}
}
return ids
})
// `promptIds` is the change signal; the transcript is read imperatively when
// it fires, so the selector above never pays for text extraction. The client
// goes through a ref so the memo keys on the SIGNAL alone — an accessor whose
// identity churned would otherwise re-derive every render, which is exactly
// the streaming cost this is here to avoid.
const aui = useAui()
const auiRef = useRef(aui)
auiRef.current = aui
const previousRef = useRef<TimelineEntry[]>([])
const entries = useMemo(() => {
const rows: TimelineSourceMessage[] = []
for (const message of auiRef.current.thread().getState().messages) {
if (message.role === 'user') {
rows.push({ id: message.id, role: 'user', text: userPromptText(message.content) })
}
}
const next = deriveTimelineEntries(rows)
// Hand back the PREVIOUS array when nothing user-visible moved. Blank and
// background-notification prompts are filtered out, so a new id can leave
// the rail identical — without this, that re-renders both subtrees and
// restarts the measure effect for no visible change.
if (sameTimelineEntries(previousRef.current, next)) {
return previousRef.current
}
previousRef.current = next
return next
// promptIds is the intentional re-eval TRIGGER, not a value the derivation
// reads (the transcript comes off the ref) — same shape as ChatRoutesSurface's
// gatewayState memo in app/contrib/controller.tsx.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [promptIds])
const [activeIndex, setActiveIndex] = useState(0)
const [open, setOpen] = useState(false)
const closeTimerRef = useRef<number | undefined>(undefined)
const rootRef = useRef<HTMLDivElement | null>(null)
const jump = useCallback((id: string) => scrollToPrompt(rootRef.current, id), [])
// Hover sync lives on the DOM, not in React state — the tick and its popover
// row are siblings in different subtrees, so a shared index-keyed paint() lights
// both without a re-render (and without coupling them through a parent atom).
const tickRefs = useRef<(HTMLSpanElement | null)[]>([])
const rowRefs = useRef<(HTMLButtonElement | null)[]>([])
// Hover sync: light the tick + its popover row, and scroll that row into view
// when the list overflows so the hovered prompt is always visible.
const paint = useCallback((index: number, on: boolean) => {
const tick = tickRefs.current[index]
if (tick) {
tick.style.opacity = on ? '1' : ''
}
const row = rowRefs.current[index]
row?.classList.toggle('bg-(--ui-row-hover-background)', on)
if (on) {
row?.scrollIntoView({ block: 'nearest' })
}
}, [])
const keepOpen = useCallback(() => {
window.clearTimeout(closeTimerRef.current)
setOpen(true)
}, [])
const closeSoon = useCallback(() => {
window.clearTimeout(closeTimerRef.current)
closeTimerRef.current = window.setTimeout(() => setOpen(false), HOVER_CLOSE_MS)
}, [])
useEffect(() => () => window.clearTimeout(closeTimerRef.current), [])
useEffect(() => {
// Below the threshold the rail renders null, so measuring prompt offsets
// buys nothing — bail before touching layout at all.
if (entries.length < MIN_ENTRIES) {
return
}
const viewport = ownViewport(rootRef.current)
if (!viewport) {
return
}
let raf = 0
const compute = () => {
raf = 0
// Pinned to the bottom (the entire streaming steady-state): the active
// prompt is simply the last one. Skipping the walk matters — it reads a
// rect per user message per scroll frame, and interleaved with React's
// streaming style writes each read forces a full reflow (the single
// hottest frame in the multitab profile).
if (viewport.dataset.following === 'true') {
setActiveIndex(prev => (prev === entries.length - 1 ? prev : entries.length - 1))
return
}
const top = viewport.getBoundingClientRect().top
const offsets = entries.map(entry => {
const node = viewport.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(entry.id)}"]`)
return node ? node.getBoundingClientRect().top - top : null
})
const next = activeTimelineIndex(offsets)
setActiveIndex(prev => (prev === next ? prev : next))
}
const onScroll = () => {
if (!raf) {
raf = requestAnimationFrame(compute)
}
}
// Initial compute rides the same rAF batching as scroll. A sync call here
// reads getBoundingClientRect for every user message while other commit
// effects are still writing styles — on a session switch that interleaving
// forces a full reflow per read on a large transcript. One rAF later the
// reads batch into a single layout pass, and back-to-back entries updates
// (prefetch paint, then resume reconcile) coalesce into one compute.
onScroll()
viewport.addEventListener('scroll', onScroll, { passive: true })
return () => {
viewport.removeEventListener('scroll', onScroll)
if (raf) {
cancelAnimationFrame(raf)
}
}
}, [entries])
if (entries.length < MIN_ENTRIES) {
return null
}
return (
<div
aria-label="Conversation timeline"
className="group/timeline pointer-events-auto absolute right-0 top-1/2 z-40 flex -translate-y-1/2 flex-col items-end"
data-slot="thread-timeline"
data-suppress-pane-reveal=""
onMouseEnter={keepOpen}
onMouseLeave={closeSoon}
ref={rootRef}
role="navigation"
>
<TimelineTicks activeIndex={activeIndex} entries={entries} onHover={paint} onJump={jump} tickRefs={tickRefs} />
<TimelinePopover
activeIndex={activeIndex}
entries={entries}
onHover={paint}
onJump={jump}
open={open}
rowRefs={rowRefs}
/>
</div>
)
}
const TimelinePopover: FC<{
activeIndex: number
entries: TimelineEntry[]
onHover: (index: number, on: boolean) => void
onJump: (id: string) => void
open: boolean
rowRefs: React.RefObject<(HTMLButtonElement | null)[]>
}> = ({ activeIndex, entries, onHover, onJump, open, rowRefs }) => {
// The rail is the always-visible part; this list is not built until the
// pointer first opens it. The SHELL always renders so the opacity/translate
// transition has a node to animate — only the N rows are deferred, and they
// stay mounted afterwards so the close fade still has content.
const [everOpened, setEverOpened] = useState(open)
if (open && !everOpened) {
setEverOpened(true)
}
return (
<div
className={cn(
POPOVER_SHELL,
open ? 'pointer-events-auto opacity-100 translate-x-0' : 'pointer-events-none translate-x-1 opacity-0'
)}
data-slot="thread-timeline-popover"
>
{everOpened &&
entries.map((entry, index) => (
<button
aria-label={entry.preview}
className={cn(ROW_CLASS, index === activeIndex && 'bg-(--ui-row-active-background) text-foreground')}
key={entry.id}
onClick={() => onJump(entry.id)}
ref={listRef(rowRefs, index)}
type="button"
{...hoverProps(index, onHover)}
>
<span className="block w-full min-w-0 truncate font-medium leading-snug text-foreground">
{entry.preview}
</span>
</button>
))}
</div>
)
}
const TimelineTicks: FC<{
activeIndex: number
entries: TimelineEntry[]
onHover: (index: number, on: boolean) => void
onJump: (id: string) => void
tickRefs: React.RefObject<(HTMLSpanElement | null)[]>
}> = ({ activeIndex, entries, onHover, onJump, tickRefs }) => (
<div className="flex flex-col items-end py-1" data-slot="thread-timeline-ticks">
{entries.map((entry, index) => (
<button
aria-label={entry.preview}
className="flex h-2 w-7 cursor-pointer items-center justify-end pr-1"
key={entry.id}
onClick={() => onJump(entry.id)}
type="button"
{...hoverProps(index, onHover)}
>
<span
className={cn(
'block h-px w-3 transition-opacity duration-100 ease-out',
index === activeIndex ? 'bg-(--theme-primary)' : 'dither text-(--ui-text-quaternary) opacity-70'
)}
ref={listRef(tickRefs, index)}
/>
</button>
))}
</div>
)
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { formatMessageTimestamp, formatTimelineRange, formatTimelineTimestamp } from './timestamp'
const labels = {
today: (time: string) => `Today at ${time}`,
yesterday: (time: string) => `Yesterday at ${time}`
}
describe('formatMessageTimestamp', () => {
it('returns an empty string for missing values', () => {
expect(formatMessageTimestamp(undefined, labels)).toBe('')
expect(formatMessageTimestamp('not-a-date', labels)).toBe('')
})
it('uses the today label for timestamps earlier today', () => {
const now = new Date()
const earlierToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 30)
expect(formatMessageTimestamp(earlierToday, labels)).toMatch(/^Today at /)
})
it('uses the yesterday label for timestamps the prior day', () => {
const now = new Date()
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 0)
yesterday.setDate(yesterday.getDate() - 1)
expect(formatMessageTimestamp(yesterday, labels)).toMatch(/^Yesterday at /)
})
it('falls back to an absolute format for older timestamps', () => {
const old = new Date(2020, 0, 15, 9, 30)
const out = formatMessageTimestamp(old, labels)
expect(out).not.toMatch(/^Today at /)
expect(out).not.toMatch(/^Yesterday at /)
expect(out.length).toBeGreaterThan(0)
})
})
describe('precise timeline timestamps', () => {
it('includes seconds and milliseconds for an event', () => {
const local = new Date(2026, 4, 1, 13, 2, 3, 456)
const formatted = formatTimelineTimestamp(local.getTime() / 1000)
expect(formatted).toMatch(/13|1/)
expect(formatted).toContain('02')
expect(formatted).toContain('03')
expect(formatted).toContain('456')
})
it('renders start and finish as a range', () => {
const start = new Date(2026, 4, 1, 13, 2, 3, 456).getTime() / 1000
const finish = start + 1.25
expect(formatTimelineRange(start, finish)).toBe(
`${formatTimelineTimestamp(start)}${formatTimelineTimestamp(finish)}`
)
})
it('returns an empty string for invalid timeline values', () => {
expect(formatTimelineTimestamp(undefined)).toBe('')
expect(formatTimelineTimestamp(Number.NaN)).toBe('')
expect(formatTimelineRange(undefined, 10)).toBe('')
})
})
@@ -0,0 +1,68 @@
import { fmtClock, fmtDayTime } from '@/lib/time'
const fmtTimelineClock = new Intl.DateTimeFormat(undefined, {
fractionalSecondDigits: 3,
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
})
const timelineDate = (seconds: number | undefined): Date | null => {
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0) {
return null
}
const date = new Date(seconds * 1000)
return Number.isNaN(date.getTime()) ? null : date
}
/** Millisecond-precise local clock for transcript activity boundaries. */
export function formatTimelineTimestamp(seconds: number | undefined): string {
const date = timelineDate(seconds)
return date ? fmtTimelineClock.format(date) : ''
}
export function formatTimelineRange(start: number | undefined, end: number | undefined): string {
const from = formatTimelineTimestamp(start)
if (!from) {
return ''
}
const to = formatTimelineTimestamp(end)
return to ? `${from}${to}` : from
}
function startOfDay(d: Date): number {
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
}
export function formatMessageTimestamp(
value: Date | string | number | undefined,
labels: { today: (time: string) => string; yesterday: (time: string) => string }
): string {
if (!value) {
return ''
}
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
const dayDelta = Math.round((startOfDay(new Date()) - startOfDay(date)) / 86_400_000)
if (dayDelta === 0) {
return labels.today(fmtClock.format(date))
}
if (dayDelta === 1) {
return labels.yesterday(fmtClock.format(date))
}
return fmtDayTime.format(date)
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { resolveShowEarlierAction } from './transcript-window'
describe('resolveShowEarlierAction', () => {
it('spends the already-materialized DOM page first', () => {
expect(resolveShowEarlierAction(3, true)).toBe('dom')
expect(resolveShowEarlierAction(3, false)).toBe('dom')
})
it('expands the store window once the DOM page is exhausted', () => {
expect(resolveShowEarlierAction(0, true)).toBe('window')
})
it('is a no-op when neither DOM nor store has older content', () => {
expect(resolveShowEarlierAction(0, false)).toBe(null)
})
})
@@ -0,0 +1,34 @@
import { createContext, type ReactNode, useContext } from 'react'
export interface TranscriptWindowValue {
/** Store holds older messages the runtime window has not materialized. */
olderAvailable: boolean
/** Pull one more page of older messages out of the session store. */
expandWindow: () => void
}
const TranscriptWindowContext = createContext<TranscriptWindowValue>({
olderAvailable: false,
expandWindow: () => {}
})
export function TranscriptWindowProvider({ children, value }: { children: ReactNode; value: TranscriptWindowValue }) {
return <TranscriptWindowContext.Provider value={value}>{children}</TranscriptWindowContext.Provider>
}
export function useTranscriptWindow(): TranscriptWindowValue {
return useContext(TranscriptWindowContext)
}
/**
* "Show earlier" pages the DOM budget first and only then asks the store for
* more messages — the DOM page is already-materialized content, so spending it
* first keeps the click cheap and the store window as small as it can be.
*/
export function resolveShowEarlierAction(hiddenCount: number, olderAvailable: boolean): 'dom' | 'window' | null {
if (hiddenCount > 0) {
return 'dom'
}
return olderAvailable ? 'window' : null
}
@@ -0,0 +1,61 @@
// The transcript's activity row has to account for every second the app spends
// claiming to work. Before this, it ran off a text-length signature and only
// deferred to the tail part, so it went silent — and stopped counting — in the
// gaps: between a tool result landing and the next call arriving, and while a
// hoisted tool that renders nothing was in flight.
import { describe, expect, it } from 'vitest'
import { activitySignature, toolNarratesWait } from './turn-activity'
const text = (value: string) => ({ text: value, type: 'text' })
const call = (toolName: string, settled: boolean) => ({
toolName,
type: 'tool-call',
...(settled ? { result: 'ok' } : {})
})
describe('activitySignature', () => {
it('changes when a tool call lands its result', () => {
// The result MUTATES the existing part: same part count, same text. A
// signature blind to it reads the finished call as more of the same
// silence, so the gap after it gets dated from when the call started.
expect(activitySignature([text('working'), call('terminal', false)])).not.toBe(
activitySignature([text('working'), call('terminal', true)])
)
})
it('changes when prose streams and when a part is appended', () => {
const base = activitySignature([text('wor')])
expect(activitySignature([text('working')])).not.toBe(base)
expect(activitySignature([text('wor'), call('read_file', false)])).not.toBe(base)
})
it('is stable while nothing visible happens', () => {
const parts = [text('working'), call('terminal', true)]
expect(activitySignature(parts)).toBe(activitySignature([...parts]))
})
})
describe('toolNarratesWait', () => {
it('defers to a tool call in flight — it has its own row and timer', () => {
expect(toolNarratesWait([text('working'), call('terminal', false)])).toBe(true)
})
it('does not defer to a settled call: the gap after it belongs to nobody', () => {
expect(toolNarratesWait([text('working'), call('terminal', true)])).toBe(false)
})
it('does not defer to silent tools, which render nothing to narrate with', () => {
expect(toolNarratesWait([call('todo', false)])).toBe(false)
expect(toolNarratesWait([call('react_to_message', false)])).toBe(false)
})
it('defers to a call in flight even when a later part follows it', () => {
// Tail-part-only detection missed this: a turn that starts prose while a
// call is still running showed the row under the tool's own timer.
expect(toolNarratesWait([call('terminal', false), text('meanwhile')])).toBe(true)
})
})
@@ -0,0 +1,60 @@
import { isSilentTool } from '@/lib/tool-render-class'
/**
* Seconds of silence before an unnamed wait earns a row of its own.
*
* Long enough that the pause between one tool call finishing and the next
* arriving stays silent — a row that appeared for 300ms between every call
* would strobe down a long run — short enough that a real gap is timed almost
* as soon as it starts.
*/
export const TURN_QUIET_S = 2
export interface ActivityPart {
result?: unknown
text?: unknown
toolName?: string
type: string
}
/**
* What the tail message has produced so far, as a value that changes exactly
* when the turn makes visible progress.
*
* Part count and text length cover streamed prose and each new call. Settled
* calls are counted separately because a result lands by MUTATING the part
* that was already there: neither the count nor the text changes, so a
* signature without it reads a finished tool call as more of the same silence
* and dates the gap after it from whenever the call started.
*/
export function activitySignature(content: readonly ActivityPart[]): string {
let textLength = 0
let settledTools = 0
for (const part of content) {
if (typeof part.text === 'string') {
textLength += part.text.length
}
if (part.type === 'tool-call' && part.result !== undefined) {
settledTools += 1
}
}
return `${content.length}:${textLength}:${settledTools}`
}
/**
* Whether a tool call is already narrating this wait.
*
* A call in flight renders its own row, with its own timer, so a second
* spinner under it would count the same seconds twice. Silent tools don't:
* `todo` is hoisted to its own panel and a reaction's UI is the emoji landing
* on the bubble, so neither leaves anything on screen to time — a wait on one
* of those is as unnarrated as a wait on nothing at all.
*/
export function toolNarratesWait(content: readonly ActivityPart[]): boolean {
return content.some(
part => part.type === 'tool-call' && part.result === undefined && !isSilentTool(part.toolName ?? '')
)
}
@@ -0,0 +1,119 @@
// The gap case: the agent is working, the composer's arc border is on and Stop
// is armed, but the tail bubble has settled — a sealed interim row, or a turn
// whose last message completed while the agent kept going. The transcript used
// to show nothing there, and the seconds went uncounted.
import { type ThreadMessage } from '@assistant-ui/react'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { __resetElapsedTimerRegistryForTests } from '@/components/chat/activity-timer'
import { $activeSessionId, $busy, $messages, $turnStartedAt } from '@/store/session'
import { stubThreadEnvironment, ThreadRuntime, userMessage } from '../test-utils'
import { Thread } from '.'
stubThreadEnvironment()
const createdAt = new Date('2026-05-01T00:00:00.000Z')
const sessionId = 'session-turn-gap'
function assistant(id: string, content: unknown[], running: boolean): ThreadMessage {
return {
id,
role: 'assistant',
content,
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: {} }
} as unknown as ThreadMessage
}
const toolCall = (toolName: string, settled: boolean) => ({
type: 'tool-call',
toolCallId: `${toolName}-1`,
toolName,
args: {},
...(settled ? { result: 'ok' } : {})
})
const Harness = ({ messages }: { messages: ThreadMessage[] }) => (
<ThreadRuntime messages={messages}>
<Thread />
</ThreadRuntime>
)
const timerText = (value: string) => screen.getAllByText((_, node) => node?.textContent === value)
describe('the turn timer covers the gaps, not just the streaming', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
vi.spyOn(globalThis.document, 'hasFocus').mockReturnValue(true)
__resetElapsedTimerRegistryForTests()
$activeSessionId.set(sessionId)
$messages.set([])
$turnStartedAt.set(Date.now())
$busy.set(true)
})
afterEach(() => {
cleanup()
$activeSessionId.set(null)
$turnStartedAt.set(null)
$busy.set(false)
__resetElapsedTimerRegistryForTests()
vi.restoreAllMocks()
vi.useRealTimers()
})
it('times a settled tail bubble while the session is still working', () => {
// The sealed-bubble gap. Nothing is running at message level; the session
// is busy, so the transcript owes the user a line and a count — measured
// from the last thing the turn produced, not from when the row appeared.
const { container } = render(
<Harness
messages={[userMessage('u1', 'do the thing'), assistant('a1', [{ type: 'text', text: 'On it.' }], false)]}
/>
)
act(() => vi.advanceTimersByTime(7_000))
expect(container.querySelector('[data-slot="aui_turn-activity"]')).not.toBeNull()
expect(timerText('7s').length).toBeGreaterThan(0)
})
it('times the gap between a finished tool call and the next thing', () => {
const { container } = render(
<Harness messages={[userMessage('u1', 'read it'), assistant('a1', [toolCall('read_file', true)], true)]} />
)
act(() => vi.advanceTimersByTime(9_000))
expect(container.querySelector('[data-slot="aui_turn-activity"]')).not.toBeNull()
expect(timerText('9s').length).toBeGreaterThan(0)
})
it('stays silent under a tool call still in flight — that row has its own timer', () => {
const { container } = render(
<Harness messages={[userMessage('u1', 'run it'), assistant('a1', [toolCall('terminal', false)], true)]} />
)
act(() => vi.advanceTimersByTime(9_000))
expect(container.querySelector('[data-slot="aui_turn-activity"]')).toBeNull()
})
it('stops when the session stops working', () => {
$busy.set(false)
const { container } = render(
<Harness
messages={[userMessage('u1', 'do the thing'), assistant('a1', [{ type: 'text', text: 'Done.' }], false)]}
/>
)
act(() => vi.advanceTimersByTime(7_000))
expect(container.querySelector('[data-slot="aui_turn-activity"]')).toBeNull()
})
})
@@ -0,0 +1,4 @@
export interface RestoreMessageTarget {
text: string
userOrdinal: number | null
}
@@ -0,0 +1,146 @@
import { useAuiState, useMessageRuntime } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type MouseEvent, useCallback } from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import { triggerHaptic } from '@/lib/haptics'
import { QUICK_REACTIONS, toggleMessageReaction } from '@/store/reactions'
import { $reactionsEnabled } from '@/store/reactions-enabled'
import { $agentReactions, $localReactions, mergeReactions, setLocalReaction } from '@/store/reactions-local'
import type { MessageReaction } from '@/types/hermes'
// Stable empty identity — a fresh [] per render would re-run every consumer.
const EMPTY_REACTIONS: MessageReaction[] = []
/** The tapback a double-click lands: Apple's first Tapback, and ours. */
export const DOUBLE_CLICK_REACTION = QUICK_REACTIONS[0]
// Double-click means something else on these: links and controls act, inputs
// and code blocks select. The gesture only claims plain message body.
const NOT_A_TAPBACK = 'a, button, input, pre, select, textarea, [contenteditable="true"], [role="button"]'
/**
* Is this double-click the "heart it" gesture?
*
* `detail === 2` keeps a triple-click (select-the-paragraph) from re-firing,
* and anything the browser already gives a double-click meaning keeps it.
*/
export function isTapbackDoubleClick(event: { detail: number; target: EventTarget | null }): boolean {
if (event.detail !== 2) {
return false
}
const target = event.target
return target instanceof Element ? !target.closest(NOT_A_TAPBACK) : true
}
/** Paint the tapback locally, then persist behind it. */
function commitReaction(
messageId: string,
role: ChatMessage['role'],
rowId: number | undefined,
reactions: MessageReaction[],
emoji: null | string
): void {
// Flip the UI immediately — a tapback is direct manipulation and must never
// wait on a round-trip. Persistence follows in the background.
setLocalReaction(messageId, emoji)
void toggleMessageReaction({ id: messageId, role, rowId, reactions } as ChatMessage, emoji)
}
/**
* A message's reactions and the one way to change them.
*
* Reads the durable list off `metadata.custom`, layers this window's live
* overlays on top (the user's own click, the agent's mid-turn event), and
* hands back a `react` that paints locally first and persists behind it.
* Shared by the assistant footer slot, the user bubble's picker, and the
* double-click gesture so all three apply identical tapback semantics.
*/
export function useMessageReactions(
messageId: string,
role: ChatMessage['role']
): {
enabled: boolean
react: (emoji: null | string) => void
reactions: MessageReaction[]
} {
const reactions = useAuiState(s => {
const custom = (s.message.metadata?.custom ?? {}) as { reactions?: MessageReaction[] }
return custom.reactions ?? EMPTY_REACTIONS
})
const rowId = useAuiState(s => {
const custom = (s.message.metadata?.custom ?? {}) as { rowId?: number }
return custom.rowId
})
const enabled = useStore($reactionsEnabled)
const localAll = useStore($localReactions)
const agentLive = useStore($agentReactions)
return {
enabled,
react: useCallback(
(emoji: null | string) => commitReaction(messageId, role, rowId, reactions, emoji),
[messageId, reactions, role, rowId]
),
reactions: mergeReactions(reactions, localAll[messageId], rowId === undefined ? undefined : agentLive[rowId])
}
}
/**
* Double-click a message to heart it — the iMessage gesture.
*
* Reads the message's reaction state lazily at event time (the same trick the
* footer uses for its text): the gesture renders nothing, so subscribing the
* perf-sensitive message root to every reaction change would be pure cost.
* Returns `undefined` while reactions are off, so the element carries no
* listener at all.
*/
export function useTapbackDoubleClick(
messageId: string,
role: ChatMessage['role']
): ((event: MouseEvent<HTMLElement>) => void) | undefined {
const enabled = useStore($reactionsEnabled)
const messageRuntime = useMessageRuntime()
const onDoubleClick = useCallback(
(event: MouseEvent<HTMLElement>) => {
if (!isTapbackDoubleClick(event)) {
return
}
// Double-click has already selected the word underneath — the tapback,
// not a stray selection, is what the gesture meant.
window.getSelection()?.removeAllRanges()
triggerHaptic('selection')
const custom = (messageRuntime.getState().metadata?.custom ?? {}) as {
reactions?: MessageReaction[]
rowId?: number
}
const reactions = custom.reactions ?? EMPTY_REACTIONS
// Same toggle semantics as the picker: a second double-click retracts.
const mine = mergeReactions(reactions, $localReactions.get()[messageId]).find(
reaction => reaction.author === 'user'
)
commitReaction(
messageId,
role,
custom.rowId,
reactions,
mine?.emoji === DOUBLE_CLICK_REACTION ? null : DOUBLE_CLICK_REACTION
)
},
[messageId, messageRuntime, role]
)
return enabled ? onDoubleClick : undefined
}
@@ -0,0 +1,928 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'
import {
type ClipboardEvent,
type FC,
type FocusEvent,
type FormEvent,
type KeyboardEvent,
type DragEvent as ReactDragEvent,
useCallback,
useEffect,
useRef,
useState
} from 'react'
import { ComposerDirectiveActions } from '@/app/chat/composer/directive-actions'
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from '@/app/chat/composer/drop-affordance'
import {
type ComposerInsertMode,
focusComposerInput,
markActiveComposer,
onComposerFocusRequest,
onComposerInsertRequest,
releaseActiveComposer
} from '@/app/chat/composer/focus'
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
import { rebuildAroundCaret, triggerKeyUpHandler } from '@/app/chat/composer/hooks/use-composer-trigger'
import { useComposerUndo } from '@/app/chat/composer/hooks/use-composer-undo'
import { useEmojiCompletions } from '@/app/chat/composer/hooks/use-emoji-completions'
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
import {
dragHasAttachments,
droppedFileInlineRefs,
type InlineRefInput,
insertInlineRefsIntoEditor
} from '@/app/chat/composer/inline-refs'
import { chipTypedPathOnSpace, pathifyRefs } from '@/app/chat/composer/path-refs'
import {
composerPlainText,
insertComposerContentsAtCaret,
placeCaretEnd,
refChipElement,
renderComposerContents,
replaceBeforeCaret,
RICH_INPUT_SLOT
} from '@/app/chat/composer/rich-editor'
import { detectTrigger, openDirectiveScope, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
import { isRedoShortcut, isUndoShortcut } from '@/app/chat/composer/undo-history'
import { chipTypedUrlOnSpace, linkifyUrls } from '@/app/chat/composer/url-refs'
import {
extractDroppedFiles,
HERMES_PATHS_MIME,
isImagePath,
partitionDroppedFiles
} from '@/app/chat/hooks/use-composer-actions'
import { uploadComposerAttachment } from '@/app/session/hooks/use-prompt-actions'
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import {
StickyHumanMessageContainer,
StopGlyph,
USER_ACTION_ICON_BUTTON_CLASS,
USER_ACTION_ICON_SIZE,
USER_BUBBLE_BASE_CLASS
} from '@/components/assistant-ui/thread/user-message'
import { Codicon } from '@/components/ui/codicon'
import type { HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runtime'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { Loader2Icon } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
import { $terminalBackend } from '@/store/session'
import { isSessionRemote } from '@/store/session-states'
import { notifyThreadEditClose } from '@/store/thread-scroll'
interface UserEditComposerProps {
cwd: string | null
gateway: HermesGateway | null
sessionId: string | null
}
export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const aui = useAui()
const draft = useAuiState(s => s.composer.text)
const rootRef = useRef<HTMLDivElement | null>(null)
const editorRef = useRef<HTMLDivElement | null>(null)
// Capture the original draft immediately before the first edit. The runtime
// may hydrate composer.text after this component's first render, so taking a
// mount-time snapshot can incorrectly classify every later blur as dirty.
const initialDraftRef = useRef<string | null>(null)
const draftRef = useRef(draft)
const composingRef = useRef(false)
const dragDepthRef = useRef(0)
const [dragActive, setDragActive] = useState(false)
const [trigger, setTrigger] = useState<TriggerState | null>(null)
const [triggerActive, setTriggerActive] = useState(0)
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
// See index.tsx: set in keydown when the open popover consumes a nav/control
// key so the matching keyup skips refreshTrigger (timing-immune vs reading
// `trigger`, which keyup sees as already-null after Escape).
const triggerKeyConsumedRef = useRef(false)
const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top')
const [focusRequestId, setFocusRequestId] = useState(0)
const [submitting, setSubmitting] = useState(false)
// True while OS-drop files are being staged/uploaded into the session. Blocks
// submit and shows a spinner so confirming the edit can't race the async
// upload and drop the gateway-side ref before it lands in the draft.
const [staging, setStaging] = useState(false)
const expanded = draft.includes('\n')
const canSubmit = draft.trim().length > 0
const at = useAtCompletions({ cwd, gateway, sessionId })
const slash = useSlashCompletions({ gateway })
const emoji = useEmojiCompletions()
// Timers this composer schedules must not outlive it. Every callback below
// touches component state, a ref or the composer core, and this is the one
// composer that routinely unmounts mid-flight: confirming an edit tears it
// down while the 200ms submit latch is still pending, so the latch resumes
// against an unmounted tree. Two of the callbacks already carry defensive
// try/catch for the racing-teardown case; clearing the timers removes the
// race instead of surviving it.
const pendingTimeoutsRef = useRef<Set<number>>(new Set())
const scheduleTimeout = useCallback((run: () => void, delayMs: number): void => {
const id = window.setTimeout(() => {
pendingTimeoutsRef.current.delete(id)
run()
}, delayMs)
pendingTimeoutsRef.current.add(id)
}, [])
// This is the one composer that routinely unmounts, so it is where the focus
// bus leaks: confirming or cancelling an edit tears the composer down while
// `'edit'` is still the active target. Release it alongside the thread-scroll
// cleanup so keyboard routing falls back to the visible chat composer.
//
// It also drains whatever `scheduleTimeout` still has pending, which is a
// second concern under the same heading rather than a separate one: both
// are "this composer is going away", they unmount together by definition,
// and a sibling unmount-only effect would only be a second place to forget.
useEffect(
() => () => {
notifyThreadEditClose()
releaseActiveComposer('edit')
for (const id of pendingTimeoutsRef.current) {
window.clearTimeout(id)
}
pendingTimeoutsRef.current.clear()
},
[]
)
const focusEditor = useCallback(() => {
const editor = editorRef.current
focusComposerInput(editor)
if (editor) {
placeCaretEnd(editor)
}
markActiveComposer('edit')
}, [])
const requestEditFocus = useCallback(() => {
setFocusRequestId(id => id + 1)
}, [])
const rememberInitialDraft = useCallback(() => {
if (initialDraftRef.current === null) {
initialDraftRef.current = draftRef.current
}
}, [])
const appendExternalText = useCallback(
(text: string, mode: ComposerInsertMode) => {
const value = text.trim()
if (!value) {
return
}
rememberInitialDraft()
const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current
const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : ''
const next = `${base}${sep}${value}`
draftRef.current = next
aui.composer().setText(next)
const editor = editorRef.current
if (editor) {
renderComposerContents(editor, next, { trailingCommitted: true })
placeCaretEnd(editor)
}
setFocusRequestId(id => id + 1)
},
[aui, rememberInitialDraft]
)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
draftRef.current = draft
const editor = editorRef.current
if (
editor &&
(editor.childNodes.length === 0 || (document.activeElement !== editor && composerPlainText(editor) !== draft))
) {
// Inert by construction — this repaints on mount or when the editor
// isn't the one being typed into. A message opened for edit is finished
// text, so a `/command` ending it is committed and chips, matching how
// the transcript rendered that same message a moment ago.
renderComposerContents(editor, draft, { trailingCommitted: true })
if (document.activeElement === editor) {
placeCaretEnd(editor)
}
}
}, [draft])
useEffect(() => {
focusEditor()
}, [focusEditor, focusRequestId])
useEffect(() => {
const offFocus = onComposerFocusRequest(({ target }) => {
if (target === 'edit') {
setFocusRequestId(id => id + 1)
}
})
const offInsert = onComposerInsertRequest(({ mode, target, text }) => {
if (target === 'edit') {
appendExternalText(text, mode)
}
})
return () => {
offFocus()
offInsert()
}
}, [appendExternalText])
const syncDraftFromEditor = useCallback(
(editor: HTMLDivElement) => {
const nextDraft = sanitizeComposerInput(composerPlainText(editor))
if (nextDraft !== draftRef.current) {
draftRef.current = nextDraft
aui.composer().setText(nextDraft)
}
return nextDraft
},
[aui]
)
// Same stack the main composer owns, for the same reason: the editor mutates
// through `Range` to dodge Chromium's O(n²) editing pipeline, which also
// dodges its undo stack, so a paste was invisible to Cmd+Z. `rememberInitialDraft`
// already marks every mutation site (it's the dirty-edit guard), so the undo
// points ride along with it.
const syncFromEditorRef = useCallback(() => {
const editor = editorRef.current
return editor ? syncDraftFromEditor(editor) : draftRef.current
}, [syncDraftFromEditor])
const { recordUndoPoint, redo, undo, withUndoPoint } = useComposerUndo({
editorRef,
syncDraftFromEditor: syncFromEditorRef
})
const refreshTrigger = useCallback(() => {
const editor = editorRef.current
if (!editor) {
return
}
const before = textBeforeCaret(editor)
const detected = detectTrigger(before ?? composerPlainText(editor))
if (detected) {
const rect = editor.getBoundingClientRect()
const spaceAbove = rect.top
const spaceBelow = window.innerHeight - rect.bottom
setTriggerPlacement(spaceAbove < 220 && spaceBelow > spaceAbove ? 'bottom' : 'top')
}
setTrigger(detected)
// Only reset the highlight when the trigger actually changed (opened, or
// the query/kind differs). Re-detecting the *same* trigger — e.g. on a
// caret move (mouseup) or a stray refresh — must preserve the user's
// current selection instead of snapping back to the first item.
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
setTriggerActive(0)
}
}, [trigger])
const closeTrigger = useCallback(() => {
setTrigger(null)
setTriggerItems([])
setTriggerActive(0)
}, [])
const triggerAdapter: Unstable_TriggerAdapter | null =
trigger?.kind === '@'
? at.adapter
: trigger?.kind === '/'
? slash.adapter
: trigger?.kind === ':'
? emoji.adapter
: null
useEffect(() => {
if (!trigger || !triggerAdapter?.search) {
setTriggerItems([])
return
}
setTriggerItems(triggerAdapter.search(trigger.query))
}, [trigger, triggerAdapter])
useEffect(() => {
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
}, [triggerItems.length])
const triggerLoading =
trigger?.kind === '@'
? at.loading
: trigger?.kind === '/'
? slash.loading
: trigger?.kind === ':'
? emoji.loading
: false
const replaceTriggerWithChip = useCallback(
(item: Unstable_TriggerItem) => {
const editor = editorRef.current
if (!editor || !trigger) {
return
}
rememberInitialDraft()
recordUndoPoint()
const serialized = hermesDirectiveFormatter.serialize(item)
const starter = serialized.endsWith(':')
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
const finish = () => {
draftRef.current = composerPlainText(editor)
aui.composer().setText(draftRef.current)
requestEditFocus()
starter ? scheduleTimeout(refreshTrigger, 0) : closeTrigger()
}
// In place first, spanning Chromium's split text nodes (see
// rangeBeforeCaret). The re-render fallback only runs when the caret
// genuinely can't anchor the token — it rebuilds from serialized text,
// which re-chips `@` refs but resets the caret to the end.
const fragment = document.createDocumentFragment()
directive
? fragment.append(refChipElement(directive[1], directive[2]), document.createTextNode(' '))
: fragment.append(document.createTextNode(text))
if (!replaceBeforeCaret(editor, trigger.tokenLength, fragment)) {
rebuildAroundCaret(editor, trigger.tokenLength, text)
}
finish()
},
[
aui,
closeTrigger,
recordUndoPoint,
refreshTrigger,
rememberInitialDraft,
requestEditFocus,
scheduleTimeout,
trigger
]
)
const insertRefStrings = useCallback(
(refs: InlineRefInput[]) => {
const editor = editorRef.current
if (!editor || refs.length === 0) {
return false
}
// Bank BEFORE the insert — insertInlineRefsIntoEditor mutates in place, so
// recording after it would snapshot the state we're trying to undo to.
const undone = withUndoPoint(() => insertInlineRefsIntoEditor(editor, refs) !== null)
if (!undone) {
return false
}
rememberInitialDraft()
const nextDraft = composerPlainText(editor)
draftRef.current = nextDraft
aui.composer().setText(nextDraft)
requestEditFocus()
return true
},
[aui, rememberInitialDraft, requestEditFocus, withUndoPoint]
)
const insertDroppedRefs = useCallback(
(candidates: ReturnType<typeof extractDroppedFiles>) => insertRefStrings(droppedFileInlineRefs(candidates, cwd)),
[cwd, insertRefStrings]
)
// OS/Finder drops carry an absolute path on THIS machine — the gateway can't
// read it in remote mode, and an image needs its bytes uploaded for vision.
// Stage each through the same file.attach/image.attach_bytes pipeline the main
// composer uses, then insert the *gateway-side* ref the agent can resolve —
// never the raw local path (the MahmoudR remote-attach bug, which the main
// composer fixes but this edit composer used to reproduce).
const uploadOsDropRefs = useCallback(
async (osDrops: ReturnType<typeof extractDroppedFiles>): Promise<InlineRefInput[]> => {
if (!gateway || !sessionId) {
// No session to stage into — best-effort inline refs (matches old path).
return droppedFileInlineRefs(osDrops, cwd)
}
const remote = isSessionRemote(sessionId)
const requestGateway = <T,>(method: string, params?: Record<string, unknown>) =>
gateway.request<T>(method, params)
const refs: InlineRefInput[] = []
for (const candidate of osDrops) {
const path = candidate.path || ''
if (!path) {
continue
}
const kind: ComposerAttachment['kind'] =
candidate.file?.type.startsWith('image/') || isImagePath(candidate.file?.name || path) ? 'image' : 'file'
try {
const uploaded = await uploadComposerAttachment(
{ detail: path, id: attachmentId(kind, path), kind, label: pathLabel(path), path },
{ backendCwd: cwd, remote, requestGateway, sessionId, terminalBackend: $terminalBackend.get() }
)
const ref = attachmentDisplayText(uploaded)
if (ref) {
refs.push(ref)
}
} catch (err) {
notifyError(err, t.desktop.dropFiles)
}
}
return refs
},
[cwd, gateway, sessionId, t.desktop.dropFiles]
)
const resetDragState = useCallback(() => {
dragDepthRef.current = 0
setDragActive(false)
}, [])
const handleDragEnter = (event: ReactDragEvent<HTMLElement>) => {
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
event.preventDefault()
dragDepthRef.current += 1
if (!dragActive) {
setDragActive(true)
}
}
const handleDragOver = (event: ReactDragEvent<HTMLElement>) => {
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
}
const handleDragLeave = (event: ReactDragEvent<HTMLElement>) => {
event.preventDefault()
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) {
setDragActive(false)
}
}
const handleDrop = (event: ReactDragEvent<HTMLElement>) => {
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
const candidates = extractDroppedFiles(event.dataTransfer)
if (!candidates.length) {
return
}
event.preventDefault()
event.stopPropagation()
resetDragState()
// In-app drags (project tree / gutter) are workspace-relative paths that
// resolve on the gateway as-is, so they stay inline refs. OS drops need to
// be staged + uploaded first, then their gateway-side ref is inserted.
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
if (insertDroppedRefs(inAppRefs)) {
triggerHaptic('selection')
}
if (osDrops.length) {
setStaging(true)
void uploadOsDropRefs(osDrops)
.then(refs => {
if (insertRefStrings(refs)) {
triggerHaptic('selection')
}
})
.finally(() => setStaging(false))
}
}
const flushEditorToDraft = useCallback(
(editor: HTMLDivElement) => {
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
editor.replaceChildren()
}
rememberInitialDraft()
const nextDraft = syncDraftFromEditor(editor)
scheduleTimeout(refreshTrigger, 0)
return nextDraft
},
[refreshTrigger, rememberInitialDraft, scheduleTimeout, syncDraftFromEditor]
)
const handleInput = (event: FormEvent<HTMLDivElement>) => {
if (composingRef.current) {
return
}
flushEditorToDraft(event.currentTarget)
}
// Native typing/deleting still goes through Chromium's editing pipeline, whose
// undo stack we've taken over — bank the pre-edit state here, while
// `beforeinput` can still see the old text.
const handleBeforeInput = (event: FormEvent<HTMLDivElement>) => {
const inputType = (event.nativeEvent as InputEvent).inputType
if (inputType === 'historyUndo' || inputType === 'historyRedo') {
return
}
recordUndoPoint({ coalesce: inputType === 'insertText' || inputType === 'deleteContentBackward' })
}
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
const pastedText = sanitizeComposerInput(event.clipboardData.getData('text'))
if (!pastedText || DATA_IMAGE_URL_RE.test(pastedText.trim())) {
event.preventDefault()
return
}
event.preventDefault()
rememberInitialDraft()
recordUndoPoint()
// Links land as `@url:` chips, same as the main composer — including
// consuming an open `@url:` scope rather than stacking a second directive
// in front of the chip.
insertComposerContentsAtCaret(
event.currentTarget,
pathifyRefs(linkifyUrls(pastedText)),
openDirectiveScope(event.currentTarget)
)
syncDraftFromEditor(event.currentTarget)
}
const submitEdit = (editor: HTMLDivElement) => {
if (composingRef.current) {
return
}
const nextDraft = syncDraftFromEditor(editor)
if (submitting || staging || !nextDraft.trim()) {
return
}
setSubmitting(true)
// `aui.composer().send()` throws "Composer is not available" when the edit
// composer core has been torn down (e.g. a blur-driven cancel raced the
// click). Reset `submitting` on failure so the arrow can't wedge on `true`
// and leave revert as the only way out (#49903 is the same unguarded-core
// hazard on the main composer).
try {
aui.composer().send()
// Clear latch after cooldown to allow re-submission. This prevents rapid
// double-Enter but doesn't require tracking when onEdit settles (which may
// be synchronous or async, and whose promise we don't have access to).
scheduleTimeout(() => {
setSubmitting(false)
}, 200)
} catch {
setSubmitting(false)
}
}
const handleEditBlur = useCallback(
(event: FocusEvent<HTMLDivElement>) => {
const nextTarget = event.relatedTarget
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
return
}
scheduleTimeout(() => {
const root = rootRef.current
const active = document.activeElement
if (submitting || (root && active && root.contains(active))) {
return
}
const editor = editorRef.current
// Dirty edit guard: when the user actually typed something, blur must
// not cancel the composer — that would discard their in-flight
// edits. Compare against the draft captured immediately before the
// first edit; when no edit event occurred, the current hydrated draft
// is the clean baseline.
const initialDraft = initialDraftRef.current ?? draftRef.current
if (editor && syncDraftFromEditor(editor) !== initialDraft) {
closeTrigger()
return
}
closeTrigger()
// Swallow the unbound-core throw: if the composer core was already torn
// down (a send/cancel raced this timer), cancel() throws "Composer is
// not available" as an uncaught renderer error. Nothing to cancel then.
try {
aui.composer().cancel()
} catch {
// Composer core already gone — the edit is closing anyway.
}
}, 80)
},
[aui, closeTrigger, scheduleTimeout, submitting, syncDraftFromEditor]
)
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
// Self-heal a stale composition flag (same recovery as the main composer's
// handleEditorKeyDown, #44135): compositionend can be missed, and a wedged
// composingRef would swallow every Enter until the edit composer remounts.
if (composingRef.current && !event.nativeEvent.isComposing) {
composingRef.current = false
}
// IME composition: Enter confirms composed text, not a message submission.
if (composingRef.current || event.nativeEvent.isComposing) {
return
}
// IME commit Enter still carrying keyCode 229 (VK_PROCESSKEY) after
// compositionend — same guard as the main composer.
if (event.key === 'Enter' && event.keyCode === 229) {
return
}
if (trigger && triggerItems.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
triggerKeyConsumedRef.current = true
setTriggerActive(idx => (idx + 1) % triggerItems.length)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
triggerKeyConsumedRef.current = true
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
return
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault()
triggerKeyConsumedRef.current = true
const item = triggerItems[triggerActive]
if (item) {
replaceTriggerWithChip(item)
}
return
}
if (event.key === 'Escape') {
event.preventDefault()
triggerKeyConsumedRef.current = true
closeTrigger()
return
}
}
// Undo/redo before Escape — we own the stack, and a stray Cmd+Z must never
// fall through to something that cancels the edit outright.
if (isUndoShortcut(event.nativeEvent)) {
event.preventDefault()
undo()
return
}
if (isRedoShortcut(event.nativeEvent)) {
event.preventDefault()
redo()
return
}
if (event.key === 'Escape') {
event.preventDefault()
aui.composer().cancel()
return
}
// A typed link finished with a space chips like a pasted one.
if (withUndoPoint(() => chipTypedUrlOnSpace(event))) {
event.preventDefault()
rememberInitialDraft()
syncDraftFromEditor(event.currentTarget)
return
}
// Same for a bare `@path`.
if (withUndoPoint(() => chipTypedPathOnSpace(event))) {
event.preventDefault()
rememberInitialDraft()
syncDraftFromEditor(event.currentTarget)
return
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
submitEdit(event.currentTarget)
}
}
const handleKeyUp = triggerKeyUpHandler(triggerKeyConsumedRef, refreshTrigger)
return (
<ComposerPrimitive.Root className="contents" data-slot="aui_edit-composer-root">
<StickyHumanMessageContainer>
<div
className="composer-human-message-container human-execution-message-top relative flex w-full items-start rounded-md bg-(--ui-chat-surface-background)"
// A raised box over the transcript field: under window glass it keeps
// a near-opaque fill instead of thinning with the field behind it.
data-glass-raised=""
onBlur={handleEditBlur}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
ref={rootRef}
>
{trigger && (
<ComposerTriggerPopover
activeIndex={triggerActive}
items={triggerItems}
kind={trigger.kind}
loading={triggerLoading}
onHover={setTriggerActive}
onPick={replaceTriggerWithChip}
placement={triggerPlacement}
/>
)}
<div
className={cn(
USER_BUBBLE_BASE_CLASS,
'ui-prompt-input__container relative border-(--ui-stroke-secondary) data-[expanded=true]:min-h-20',
COMPOSER_DROP_FADE_CLASS,
dragActive && COMPOSER_DROP_ACTIVE_CLASS
)}
data-expanded={expanded ? 'true' : undefined}
>
<div
aria-label={copy.editMessage}
autoCapitalize="off"
autoCorrect="off"
className={cn(
'ui-prompt-input-editor__input max-h-48 w-full resize-none overflow-y-auto bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] text-foreground/95 outline-none',
'**:data-ref-text:cursor-default',
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
)}
contentEditable
data-placeholder={copy.editMessage}
data-slot={RICH_INPUT_SLOT}
onBeforeInput={handleBeforeInput}
onBlur={() => scheduleTimeout(closeTrigger, 80)}
onCompositionEnd={event => {
composingRef.current = false
flushEditorToDraft(event.currentTarget)
}}
onCompositionStart={() => {
composingRef.current = true
}}
onDragOver={handleDragOver}
onDrop={handleDrop}
onFocus={() => markActiveComposer('edit')}
onInput={handleInput}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onMouseUp={refreshTrigger}
onPaste={handlePaste}
ref={editorRef}
role="textbox"
spellCheck={false}
suppressContentEditableWarning
/>
<ComposerDirectiveActions editorRef={editorRef} />
<ComposerPrimitive.Input
asChild
className="sr-only"
submitMode="ctrlEnter"
tabIndex={-1}
unstable_focusOnScrollToBottom={false}
>
<textarea
aria-hidden
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="sr-only"
spellCheck={false}
tabIndex={-1}
/>
</ComposerPrimitive.Input>
{staging && (
<span
className="pointer-events-none absolute bottom-2 left-2 inline-flex items-center gap-1 rounded-full bg-background/80 px-1.5 py-0.5 text-[0.62rem] text-muted-foreground backdrop-blur-[1px]"
data-slot="aui_edit-staging"
>
<Loader2Icon className="size-3 animate-spin" />
{copy.attachingFile}
</span>
)}
<button
aria-label={copy.sendEdited}
className={cn('absolute right-2 bottom-2 size-5', USER_ACTION_ICON_BUTTON_CLASS)}
disabled={!canSubmit || submitting || staging}
onClick={() => {
const editor = editorRef.current
if (editor) {
submitEdit(editor)
}
}}
// Keep focus in the editor on click: macOS doesn't focus a button
// on mousedown, so without this the arrow-click blurs the editor,
// the blur timer cancels the edit (tearing down the composer
// core), and the click's send() then throws against a dead core —
// the edit silently never sends. The restore button guards the
// same way.
onPointerDown={event => event.preventDefault()}
title={copy.sendEdited}
type="button"
>
{submitting ? StopGlyph : <Codicon name="arrow-up" size={USER_ACTION_ICON_SIZE} />}
</button>
</div>
</div>
</StickyHumanMessageContainer>
</ComposerPrimitive.Root>
)
}
@@ -0,0 +1,105 @@
import {
type AppendMessage,
AssistantRuntimeProvider,
ExportedMessageRepository,
type ThreadMessage
} from '@assistant-ui/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { assistantMessage, stubThreadEnvironment, stubThreadViewportSize, userMessage } from '../test-utils'
import { Thread } from '.'
stubThreadEnvironment()
afterEach(() => {
cleanup()
})
stubThreadViewportSize()
function Harness({ onEdit }: { onEdit: (message: AppendMessage) => Promise<void> }) {
const repository = ExportedMessageRepository.fromArray([userMessage(), assistantMessage()])
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
messageRepository: repository,
isRunning: false,
setMessages: () => {},
onNew: async () => {},
onEdit,
onCancel: async () => {},
onReload: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread cwd={null} gateway={null} sessionId="session-1" />
</AssistantRuntimeProvider>
)
}
// Regression for the desktop "editing a message, clicking the arrow does
// nothing — I have to click revert" report.
//
// On macOS a <button> does NOT take DOM focus on mousedown, so clicking the
// send arrow blurs the contenteditable (relatedTarget = null). The blur
// schedules an 80ms timer that cancels the edit, tearing down the assistant-ui
// edit-composer core. When the click's send() then runs, and again when the
// blur timer fires, cancel()/send() on a torn-down core throw "Composer is not
// available". The throw wedged the arrow (submitting stuck true) so only revert
// worked.
//
// The blur throw fires from a real setTimeout, which jsdom routes to Node as an
// `uncaughtException` (not a DOM error event), so a window 'error' probe misses
// it. Capture process-level uncaught exceptions for the duration of the gesture
// instead — an unguarded throw registers here and fails the test.
describe('edit send arrow — macOS click gesture (blur races cancel)', () => {
it('sends without an uncaught "Composer is not available" when the arrow-click blurs the editor', async () => {
const uncaught: unknown[] = []
const onUncaught = (err: unknown) => {
uncaught.push(err)
}
process.on('uncaughtException', onUncaught)
try {
const onEdit = vi.fn(async () => {})
render(<Harness onEdit={onEdit} />)
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
const editor = await screen.findByRole('textbox', { name: 'Edit message' })
await act(async () => {
editor.focus()
editor.textContent = 'edited then clicked the arrow'
fireEvent.input(editor)
})
const send = await screen.findByRole('button', { name: 'Send edited message' })
// The real gesture: mousedown on the arrow (no focus on macOS) blurs the
// editor to <body>, then the click fires the send, then the blur's 80ms
// cancel timer runs on the now-torn-down core. Wait past 80ms so the timer
// completes within the captured window.
await act(async () => {
fireEvent.pointerDown(send)
editor.blur()
fireEvent.click(send)
await new Promise(resolve => setTimeout(resolve, 200))
})
await waitFor(() => {
expect(onEdit).toHaveBeenCalledTimes(1)
})
const composerErrors = uncaught.filter(err => /Composer is not available/.test(String(err)))
expect(composerErrors).toEqual([])
} finally {
process.off('uncaughtException', onUncaught)
}
})
})
@@ -0,0 +1,318 @@
import { type AppendMessage, ExportedMessageRepository } from '@assistant-ui/react'
// Clicking a user bubble must open the inline edit composer — through the
// app's incremental external-store runtime (which reimplements capability
// resolution, incl. `edit: onEdit !== undefined`) and the stock runtime.
//
// Note: this covers the React/runtime wiring only. The Electron-level failure
// mode (titlebar -webkit-app-region:drag swallowing clicks on *stuck* sticky
// bubbles) is not reproducible in jsdom — see USER_BUBBLE_BASE_CLASS's no-drag
// carve-out in thread.tsx.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { assistantMessage, stubThreadEnvironment, stubThreadViewportSize, userMessage } from '../test-utils'
import { Thread } from '.'
stubThreadEnvironment()
afterEach(() => {
cleanup()
})
stubThreadViewportSize()
async function moveFocusOutside(editor: HTMLElement) {
const outside = window.document.createElement('button')
window.document.body.append(outside)
editor.focus()
await act(async () => {
outside.focus()
await new Promise(resolve => window.setTimeout(resolve, 120))
})
outside.remove()
}
// Mirrors chat/index.tsx: incremental runtime + messageRepository + onEdit.
function IncrementalHarness({ onEdit }: { onEdit: (message: AppendMessage) => Promise<void> }) {
const repository = ExportedMessageRepository.fromArray([userMessage(), assistantMessage()])
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
messageRepository: repository,
isRunning: false,
setMessages: () => {},
onNew: async () => {},
onEdit,
onCancel: async () => {},
onReload: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
// Control: stock external store runtime.
function StockHarness({ onEdit }: { onEdit: () => Promise<void> }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistantMessage()],
isRunning: false,
onNew: async () => {},
onEdit
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
describe('click-to-edit user message', () => {
it('opens the edit composer with the incremental runtime', async () => {
const { container } = render(<IncrementalHarness onEdit={async () => {}} />)
const bubble = await screen.findByRole('button', { name: 'Edit message' })
fireEvent.click(bubble)
await waitFor(() => {
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
})
})
it('does not submit an inline edit while IME composition is active', async () => {
const onEdit = vi.fn(async (_message: AppendMessage) => {})
render(<IncrementalHarness onEdit={onEdit} />)
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
const editor = await screen.findByRole('textbox', { name: 'Edit message' })
const editedText = 'edit me please\u4f60'
await act(async () => {
fireEvent.compositionStart(editor)
editor.textContent = editedText
fireEvent.input(editor)
fireEvent.keyDown(editor, { isComposing: true, key: 'Enter' })
})
expect(onEdit).not.toHaveBeenCalled()
await act(async () => {
fireEvent.compositionEnd(editor)
fireEvent.keyDown(editor, { key: 'Enter' })
})
await waitFor(() => expect(onEdit).toHaveBeenCalledTimes(1))
expect(onEdit).toHaveBeenCalledWith(
expect.objectContaining({
content: [{ text: editedText, type: 'text' }]
})
)
})
it('keeps a dirty inline edit open when focus leaves the composer', async () => {
const { container } = render(<IncrementalHarness onEdit={async () => {}} />)
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
const editor = await screen.findByRole('textbox', { name: 'Edit message' })
const editedText = 'edited draft that must not be discarded'
editor.textContent = editedText
fireEvent.input(editor)
await moveFocusOutside(editor)
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
expect((await screen.findByRole('textbox', { name: 'Edit message' })).textContent).toBe(editedText)
})
it('still cancels an untouched inline edit when focus leaves the composer', async () => {
const { container } = render(<IncrementalHarness onEdit={async () => {}} />)
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
const editor = await screen.findByRole('textbox', { name: 'Edit message' })
await moveFocusOutside(editor)
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeFalsy()
})
it('opens the edit composer with the stock runtime', async () => {
const { container } = render(<StockHarness onEdit={async () => {}} />)
const bubble = await screen.findByRole('button', { name: 'Edit message' })
fireEvent.click(bubble)
await waitFor(() => {
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
})
})
// A long previous prompt is capped at max-h-48 in the edit composer. Without
// an overflow rule the overflow is clipped with no way to scroll, hiding the
// tail of the prompt. The editor must scroll its own overflow (like the main
// composer's editor does).
it('keeps the edit composer editor scrollable when the prompt overflows the cap', async () => {
const { container } = render(<IncrementalHarness onEdit={async () => {}} />)
const bubble = await screen.findByRole('button', { name: 'Edit message' })
fireEvent.click(bubble)
const editor = await waitFor(() => {
const node = container.querySelector('[contenteditable="true"]')
expect(node).toBeTruthy()
return node as HTMLElement
})
expect(editor.className).toContain('max-h-48')
expect(editor.className).toContain('overflow-y-auto')
})
})
describe('Enter submission and latch behavior', () => {
it('submits the edit when Enter is pressed', async () => {
const onEdit = vi.fn(async () => {})
render(<IncrementalHarness onEdit={onEdit} />)
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
const editor = await screen.findByRole('textbox', { name: 'Edit message' })
const editedText = 'modified text for submission'
editor.textContent = editedText
fireEvent.input(editor)
fireEvent.keyDown(editor, { key: 'Enter' })
await waitFor(() => {
expect(onEdit).toHaveBeenCalledTimes(1)
})
})
it('clears the submitting latch after onEdit resolves, allowing second edit session', async () => {
const onEdit = vi.fn(async () => {})
render(<IncrementalHarness onEdit={onEdit} />)
// First edit session
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
let editor = await screen.findByRole('textbox', { name: 'Edit message' })
editor.textContent = 'first edit'
fireEvent.input(editor)
fireEvent.keyDown(editor, { key: 'Enter' })
await waitFor(() => {
expect(onEdit).toHaveBeenCalledTimes(1)
})
// Wait for the latch cooldown to clear
await new Promise(resolve => setTimeout(resolve, 300))
// Second edit session - open the editor again
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
editor = await screen.findByRole('textbox', { name: 'Edit message' })
editor.textContent = 'second edit'
fireEvent.input(editor)
fireEvent.keyDown(editor, { key: 'Enter' })
// If the latch wasn't cleared, this second submission would be blocked
await waitFor(() => {
expect(onEdit).toHaveBeenCalledTimes(2)
})
})
// Confirming an edit unmounts the composer while its 200ms submit latch is
// still pending. Left running, the callback resumes on an unmounted tree and
// calls setSubmitting, which React turns into work against a torn-down
// renderer. Under vitest that surfaces after the test file finishes, as
// "ReferenceError: window is not defined" out of resolveUpdatePriority, an
// unhandled error that fails a run in which every test passed. The delay is
// matched explicitly so unrelated library timers cannot make this pass.
it('clears the submit latch timer when confirming the edit unmounts the composer', async () => {
const LATCH_MS = 200
// jsdom under node hands back a Timeout object rather than a numeric id,
// so these are compared by identity rather than by value.
const scheduled: unknown[] = []
const cleared: unknown[] = []
const realSetTimeout = window.setTimeout.bind(window)
const realClearTimeout = window.clearTimeout.bind(window)
vi.spyOn(window, 'setTimeout').mockImplementation(((
handler: TimerHandler,
timeout?: number,
...args: unknown[]
) => {
const id = realSetTimeout(handler, timeout, ...args)
if (timeout === LATCH_MS) {
scheduled.push(id)
}
return id
}) as typeof window.setTimeout)
// `id` is typed `unknown` for the same reason the arrays above are: what
// actually arrives is whatever `setTimeout` returned, and under jsdom that
// is a Timeout object rather than the `number` the DOM lib promises.
// Declaring it `number` would have documented a shape this never sees.
vi.spyOn(window, 'clearTimeout').mockImplementation(((id?: unknown) => {
cleared.push(id)
realClearTimeout(id as number | undefined)
}) as typeof window.clearTimeout)
const onEdit = vi.fn(async () => {})
const view = render(<IncrementalHarness onEdit={onEdit} />)
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
const editor = await screen.findByRole('textbox', { name: 'Edit message' })
editor.textContent = 'an edit whose composer goes away before the latch expires'
fireEvent.input(editor)
fireEvent.keyDown(editor, { key: 'Enter' })
await waitFor(() => {
expect(onEdit).toHaveBeenCalledTimes(1)
})
expect(scheduled).toHaveLength(1)
view.unmount()
expect(cleared).toContain(scheduled[0])
})
it('inserts a newline on Shift+Enter without submitting', async () => {
const onEdit = vi.fn(async () => {})
render(<IncrementalHarness onEdit={onEdit} />)
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
const editor = await screen.findByRole('textbox', { name: 'Edit message' })
editor.textContent = 'line one'
fireEvent.input(editor)
fireEvent.keyDown(editor, { key: 'Enter', shiftKey: true })
// Shift+Enter should not call onEdit
await new Promise(resolve => window.setTimeout(resolve, 50))
expect(onEdit).not.toHaveBeenCalled()
// The editor should allow the newline to be inserted (by not preventing default)
// We don't simulate actual newline insertion here (requires complex DOM manipulation)
// but we verify the guard did not block it
})
})
@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it } from 'vitest'
import { hasTextSelection } from './user-message'
afterEach(() => {
window.getSelection()?.removeAllRanges()
document.body.replaceChildren()
})
describe('hasTextSelection', () => {
it('is false with nothing highlighted', () => {
expect(hasTextSelection()).toBe(false)
})
it('is true once the user has a live range', () => {
const node = document.createElement('span')
node.textContent = 'copy me'
document.body.appendChild(node)
const range = document.createRange()
range.selectNodeContents(node)
const selection = window.getSelection()!
selection.removeAllRanges()
selection.addRange(range)
expect(hasTextSelection()).toBe(true)
})
})
@@ -0,0 +1,61 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { referenceRe, WIRE_REFERENCE_KINDS } from '@/components/assistant-ui/reference-kinds'
import { UserMessageText } from './user-message-text'
afterEach(cleanup)
/**
* A sent reference must render as the chip the composer showed. These cover the
* seam where that used to break: the value's quoting is directive syntax, and a
* surface that reads it as markdown splits one reference into two wrong things.
*/
describe('a sent reference renders as the chip the composer showed', () => {
it('chips a backtick-quoted @url: instead of splitting it into code', () => {
render(
<UserMessageText text="@url:`https://github.com/NousResearch/hermes-agent/pull/74790` urls lose formatting" />
)
expect(screen.queryByTitle('https://github.com/NousResearch/hermes-agent/pull/74790')).not.toBeNull()
// The whole reference is one node — no bare `@url:` text left behind.
expect(document.body.textContent).not.toContain('@url:')
})
it('chips a backtick-quoted @file: path with spaces', () => {
render(<UserMessageText text="see @file:`apps/desktop/my notes.md` please" />)
expect(screen.queryByTitle('apps/desktop/my notes.md')).not.toBeNull()
expect(document.body.textContent).not.toContain('@file:')
})
it('chips every kind that travels in message text', () => {
// The guard against WIRE_REFERENCE_KINDS and the pattern's own alternation
// drifting apart: add a kind to one and this fails until both agree.
for (const kind of WIRE_REFERENCE_KINDS) {
expect(`@${kind}:\`some value\``.match(referenceRe()), kind).toHaveLength(1)
}
})
it('still renders a genuine code span as code', () => {
render(<UserMessageText text="run `npm test` first" />)
const code = document.querySelector('[data-slot="aui_user-inline-code"]')
expect(code?.textContent).toBe('npm test')
})
it('renders code and a reference side by side', () => {
render(<UserMessageText text="run `npm test` on @file:`apps/desktop/a b.ts` now" />)
expect(document.querySelector('[data-slot="aui_user-inline-code"]')?.textContent).toBe('npm test')
expect(screen.queryByTitle('apps/desktop/a b.ts')).not.toBeNull()
})
it('leaves a fenced block alone', () => {
render(<UserMessageText text={'before\n```ts\nconst x = 1\n```\nafter'} />)
expect(document.querySelector('[data-slot="aui_user-fence"]')?.textContent).toBe('const x = 1\n')
})
})
@@ -0,0 +1,175 @@
import type { FC } from 'react'
import { Fragment, useMemo } from 'react'
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
import { referenceRe } from '@/components/assistant-ui/reference-kinds'
import { cn } from '@/lib/utils'
// User messages should render the bare-minimum of markdown: backtick `code`
// spans and ``` fenced blocks. We deliberately don't pull in the full
// assistant Markdown pipeline (Streamdown + KaTeX + syntax highlighter)
// because user input rarely contains structured docs and the heavy pipeline
// adds a lot of runtime cost per bubble.
//
// Directive chips (`@file:`, `@image:`, ...) still resolve via DirectiveContent
// inside the plain-text segments.
interface FenceSegment {
kind: 'fence'
code: string
lang: string | null
}
interface InlineSegment {
kind: 'inline'
text: string
}
interface InlineCodeSegment {
kind: 'inline-code'
code: string
}
interface InlineTextSegment {
kind: 'inline-text'
text: string
}
type TopSegment = FenceSegment | InlineSegment
type InlineNode = InlineCodeSegment | InlineTextSegment
const FENCE_RE = /```([^\n`]*)\n([\s\S]*?)```/g
// Greedy backtick run length so ``code with `backticks` inside`` works.
const INLINE_CODE_RE = /(`+)([^`\n][\s\S]*?)\1/g
// A directive's value is BACKTICK-QUOTED whenever it needs to be (`@url:`
// always, and any path with a space), so the inline-code scanner would claim
// those backticks first and split one reference into a bare `@url:` plus a code
// span — the composer's chip, flattened on send. Directives win: this is syntax
// the composer wrote, not something the user typed as code.
/** Inline-code matches that don't overlap a directive, so a quoted directive
* value reaches DirectiveContent whole. */
function inlineCodeOutsideDirectives(text: string): RegExpMatchArray[] {
const directives = Array.from(text.matchAll(referenceRe())).map(match => ({
start: match.index ?? 0,
end: (match.index ?? 0) + match[0].length
}))
return Array.from(text.matchAll(INLINE_CODE_RE)).filter(match => {
const start = match.index ?? 0
const end = start + match[0].length
return !directives.some(directive => start < directive.end && end > directive.start)
})
}
function splitFences(text: string): TopSegment[] {
const segments: TopSegment[] = []
let cursor = 0
for (const match of text.matchAll(FENCE_RE)) {
const start = match.index ?? 0
if (start > cursor) {
segments.push({ kind: 'inline', text: text.slice(cursor, start) })
}
segments.push({
kind: 'fence',
lang: (match[1] || '').trim() || null,
code: match[2] ?? ''
})
cursor = start + match[0].length
}
if (cursor < text.length) {
segments.push({ kind: 'inline', text: text.slice(cursor) })
}
return segments
}
function splitInlineCode(text: string): InlineNode[] {
const nodes: InlineNode[] = []
let cursor = 0
for (const match of inlineCodeOutsideDirectives(text)) {
const start = match.index ?? 0
if (start > cursor) {
nodes.push({ kind: 'inline-text', text: text.slice(cursor, start) })
}
nodes.push({ kind: 'inline-code', code: match[2] })
cursor = start + match[0].length
}
if (cursor < text.length) {
nodes.push({ kind: 'inline-text', text: text.slice(cursor) })
}
return nodes
}
interface UserMessageTextProps {
text: string
className?: string
}
export const UserMessageText: FC<UserMessageTextProps> = ({ className, text }) => {
const top = useMemo(() => splitFences(text), [text])
return (
<span className={cn('block', className)} data-slot="aui_user-message-text">
{top.map((segment, segmentIndex) => {
if (segment.kind === 'fence') {
return (
<pre
className="my-1.5 max-w-full overflow-x-auto rounded-md border border-(--ui-stroke-tertiary) bg-[color-mix(in_srgb,currentColor_5%,transparent)] px-2.5 py-2 font-mono text-[0.86em] leading-snug"
data-slot="aui_user-fence"
key={`fence-${segmentIndex}`}
>
<code className="block whitespace-pre">{segment.code}</code>
</pre>
)
}
return (
<Fragment key={`inline-${segmentIndex}`}>
<InlineSegmentView text={segment.text} />
</Fragment>
)
})}
</span>
)
}
const InlineSegmentView: FC<{ text: string }> = ({ text }) => {
const nodes = useMemo(() => splitInlineCode(text), [text])
return (
// styles.css bidi hook (#44150); whitespace-pre-line makes each line its own
// UAX#9 paragraph so it resolves direction independently.
<span className="wrap-anywhere block whitespace-pre-line" data-slot="aui_user-inline-text">
{nodes.map((node, nodeIndex) =>
node.kind === 'inline-code' ? (
<code
className="mx-px rounded bg-[color-mix(in_srgb,currentColor_8%,transparent)] px-1 py-px font-mono text-[0.92em]"
data-slot="aui_user-inline-code"
key={`code-${nodeIndex}`}
>
{node.code}
</code>
) : (
// Pass plain-text bits through DirectiveContent so @file:/@url: chips
// still render. DirectiveContent already preserves whitespace.
<Fragment key={`text-${nodeIndex}`}>
<DirectiveContent text={node.text} />
</Fragment>
)
)}
</span>
)
}
@@ -0,0 +1,611 @@
import { ActionBarPrimitive, BranchPickerPrimitive, MessagePrimitive, useAuiState } from '@assistant-ui/react'
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 { ReactionBadge, ReactionPicker } from '@/components/assistant-ui/thread/message-reactions'
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'
import { UserMessageText } from '@/components/assistant-ui/thread/user-message-text'
import { Codicon } from '@/components/ui/codicon'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { StopFilled } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $gateway } from '@/store/gateway'
import { notifyThreadEditOpen } from '@/store/thread-scroll'
import { isWatchWindow } from '@/store/windows'
/** True when the user has a live text highlight (drag-select / triple-click). */
export function hasTextSelection(): boolean {
const selection = window.getSelection()
return Boolean(selection && !selection.isCollapsed && selection.toString().length > 0)
}
export function StickyHumanMessageContainer({
attachments,
children,
messageId
}: {
attachments?: ReactNode
children: ReactNode
messageId?: string
}) {
return (
// Fragment, not a wrapper: a wrapping element becomes the sticky's
// containing block (it'd stick within its own height = never). The bubble
// and attachments are flow siblings so the bubble pins against the scroller
// while attachments below it scroll away.
<>
<div
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-1"
data-message-id={messageId}
data-role="user"
data-slot="aui_user-message-root"
>
{children}
</div>
{attachments}
</>
)
}
// Shared "user bubble" base. Both the read-only message and the inline
// edit composer render the same bubble surface (rounded glass card);
// they only differ in border weight, cursor, and padding-right (the
// read-only view reserves room for the restore icon).
//
// no-drag: sticky bubbles park at --sticky-human-top (~4px), sliding under the
// titlebar's [-webkit-app-region:drag] strips (app-shell.tsx). Electron resolves
// drag regions at the compositor level — z-index and pointer-events don't help —
// so without the carve-out, clicking a stuck bubble drags the window instead of
// opening the edit composer.
export const USER_BUBBLE_BASE_CLASS =
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-y-auto rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
export const USER_ACTION_ICON_BUTTON_CLASS =
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
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
// '<sender>'] …" too). They arrive on the user role because the recipient's
// turn runs on it, but they are NOT the human speaking — render them as a
// compact attributed timeline notice instead of a user bubble.
export const AGENT_MESSAGE_RE =
/^(?:Message from (?:🤖\s*)?([^:\n(]{1,64}?)(?:\s*\(@([a-z0-9][a-z0-9_-]{0,63})\))?:\s*|\[Message from agent '([^']{1,64})'\]\s*)([\s\S]*)$/u
// sender handle -> avatar data URL. Module-level so a chat full of notices
// from one bot resolves once. Hits are cached for the window's lifetime;
// misses only briefly (30s) — an avatar can appear at any moment (bot just
// created, art backfill still running), and a permanent negative cache
// froze the 🤖 glyph until an app restart.
export const agentAvatarCache = new Map<string, null | string>()
const agentAvatarMissAt = new Map<string, number>()
const AVATAR_MISS_TTL_MS = 30_000
const agentAvatarInflight = new Map<string, Promise<null | string>>()
export async function resolveAgentAvatar(handle: string): Promise<null | string> {
const key = handle.trim().toLowerCase()
if (!key) {
return null
}
if (agentAvatarCache.has(key)) {
const hit = agentAvatarCache.get(key) ?? null
if (hit !== null) {
return hit
}
// Negative entry: honor it only within the TTL, then re-probe.
if (Date.now() - (agentAvatarMissAt.get(key) ?? 0) < AVATAR_MISS_TTL_MS) {
return null
}
agentAvatarCache.delete(key)
}
const inflight = agentAvatarInflight.get(key)
if (inflight) {
return inflight
}
const run = (async (): Promise<null | string> => {
try {
const gateway = $gateway.get()
if (!gateway) {
return null
}
const res = await gateway.request<{ profiles?: Array<{ has_avatar?: boolean; name: string }> }>('profiles.list', {
include_sessions: false
})
const profiles = res?.profiles ?? []
let profile = profiles.find(p => p.name.toLowerCase() === key)
// 'hermes' is the conventional alias for the primary profile.
if (!profile && key === 'hermes') {
profile = profiles.find(p => p.name === 'default')
}
if (!profile?.has_avatar) {
return null
}
const asset = await gateway.request<{ data?: string; found?: boolean }>('profiles.get_asset', {
asset: 'avatar',
name: profile.name
})
return asset?.found && asset.data ? asset.data : null
} catch {
// Older gateway (no profiles.* RPCs) or transient failure — the 🤖
// glyph fallback is always correct.
return null
} finally {
agentAvatarInflight.delete(key)
}
})()
agentAvatarInflight.set(key, run)
const out = await run
agentAvatarCache.set(key, out)
if (out === null) {
agentAvatarMissAt.set(key, Date.now())
}
return out
}
const AgentMessageNote: FC<{ text: string }> = ({ text }) => {
const match = AGENT_MESSAGE_RE.exec(text)
const sender = (match?.[1] || match?.[3] || 'agent').trim()
const handle = (match?.[2] || match?.[3] || sender).trim()
const body = (match?.[4] || '').trim()
const [avatar, setAvatar] = useState<null | string>(() => agentAvatarCache.get(handle.toLowerCase()) ?? null)
useEffect(() => {
let live = true
void resolveAgentAvatar(handle).then(url => {
if (live && url) {
setAvatar(url)
}
})
return () => {
live = false
}
}, [handle])
// Grok-bots shape: an inter-agent delivery is a timeline EVENT, not a
// conversation bubble — a subtle centered notice ("Message from 🤖 X"),
// with the delivered text one click away instead of shouting in the
// transcript. The recipient's reply below it stays a normal assistant
// message, so the exchange still reads in order.
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"
data-slot="aui_agent-message-note"
>
<span className="flex items-center justify-center gap-1.5">
{avatar ? (
<img alt="" aria-hidden className="size-4 shrink-0 rounded-full object-cover" src={avatar} />
) : (
<span aria-hidden className="text-[0.8125rem] leading-none">
🤖
</span>
)}
<span className="wrap-anywhere">Message from {sender}</span>
</span>
{body && (
<details className="self-center">
<summary className="cursor-pointer select-none text-center text-muted-foreground/45 hover:text-muted-foreground/70">
show message
</summary>
<div className="mt-1 max-w-[36rem] rounded-lg border border-(--ui-stroke-tertiary) px-3 py-2 text-left text-[0.75rem] leading-5 text-foreground/85">
<UserMessageText text={body} />
</div>
</details>
)}
</div>
)
}
const ProcessNotificationNote: FC<{ text: string }> = ({ text }) => {
const body = text.replace(/^\[IMPORTANT:\s*/, '').replace(/\]$/, '')
const newline = body.indexOf('\n')
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>
)
}
export const UserMessage: FC<{
onCancel?: () => Promise<void> | void
onRequestRestoreConfirm?: (messageId: string, target: RestoreMessageTarget) => void
}> = ({ onCancel, onRequestRestoreConfirm }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const messageId = useAuiState(s => s.message.id)
const content = useAuiState(s => s.message.content)
const messageText = messageContentText(content)
const threadRunning = useAuiState(s => s.thread.isRunning)
const latestUserId = useAuiState(s => {
for (let i = s.thread.messages.length - 1; i >= 0; i--) {
const message = s.thread.messages[i] as { id?: string; role?: string }
if (message.role === 'user') {
return message.id ?? null
}
}
return null
})
const runtimeUserOrdinal = useAuiState(s => {
let ordinal = 0
for (const message of s.thread.messages) {
if (message.role !== 'user') {
continue
}
if (message.id === s.message.id) {
return ordinal
}
ordinal += 1
}
return null
})
const attachmentRefs = useAuiState(s => {
const custom = (s.message.metadata?.custom ?? {}) as { attachmentRefs?: unknown }
return messageAttachmentRefs(custom.attachmentRefs)
})
const [pickerOpen, setPickerOpen] = useState(false)
const { enabled: reactionsEnabled, react, reactions: shownReactions } = useMessageReactions(messageId, 'user')
const pickEmoji = useCallback(
(emoji: null | string) => {
setPickerOpen(false)
react(emoji)
},
[react]
)
// Sticky human bubbles clamp to ~2 lines with a soft fade so a long prompt
// doesn't dominate the viewport while the response streams underneath; the
// clamp lifts on hover / focus (see styles.css). We measure the *unclamped*
// inner wrapper so the ResizeObserver only fires on real content / width
// changes, not on every frame while the outer max-height animates open.
const clampInnerRef = useRef<HTMLDivElement | null>(null)
const [bodyClamped, setBodyClamped] = useState(false)
const lastClampHeightRef = useRef(-1)
const lineHeightRef = useRef(0)
// Watch windows spectate a subagent run driven elsewhere — prompts can't be
// edited, restored, or stopped from here. The bubble stays a button that
// toggles the 2-line clamp so long prompts are still fully readable.
const readOnly = isWatchWindow()
const [expanded, setExpanded] = useState(false)
const clampActive = !(readOnly && expanded)
const measureClamp = useCallback((entries: readonly ResizeObserverEntry[]) => {
const inner = clampInnerRef.current
const outer = inner?.parentElement
if (!inner || !outer) {
return
}
// Prefer the size the ResizeObserver already computed — reading
// `scrollHeight` outside RO timing forces a synchronous layout, and with
// many user bubbles observed at once those reads interleave with the
// style write below into a read-write-read reflow cascade.
const entryHeight = entries.find(entry => entry.target === inner)?.borderBoxSize?.[0]?.blockSize
const fullHeight = Math.ceil(entryHeight ?? inner.scrollHeight)
if (fullHeight === lastClampHeightRef.current) {
return
}
lastClampHeightRef.current = fullHeight
// Line-height is stable for the life of the bubble (font settings don't
// change under it) — resolve the computed style once.
if (!lineHeightRef.current) {
const styles = getComputedStyle(inner)
lineHeightRef.current = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20
}
outer.style.setProperty('--human-msg-full', `${fullHeight}px`)
setBodyClamped(fullHeight > lineHeightRef.current * 2 + 1)
}, [])
useResizeObserver(measureClamp, clampInnerRef)
// Injected background-process notification, not a human prompt — render the
// compact system-style notice (after all hooks above have run).
if (PROCESS_NOTIFICATION_RE.test(messageText.trim())) {
return (
<MessagePrimitive.Root
className="flex w-full min-w-0 flex-col items-stretch"
data-role="user"
data-slot="aui_user-message-root"
>
<ProcessNotificationNote text={messageText.trim()} />
<MessageTimelineTimestamp className="self-center" />
</MessagePrimitive.Root>
)
}
// Agent-to-agent delivery, not a human prompt — attributed inter-agent card.
if (AGENT_MESSAGE_RE.test(messageText.trim())) {
return (
<MessagePrimitive.Root
className="flex w-full min-w-0 flex-col items-stretch pb-(--conversation-turn-gap)"
data-role="user"
data-slot="aui_user-message-root"
>
<AgentMessageNote text={messageText.trim()} />
</MessagePrimitive.Root>
)
}
const hasBody = messageText.trim().length > 0
const isLatestUser = messageId === latestUserId
const showStop = !readOnly && isLatestUser && threadRunning && Boolean(onCancel)
// Restore (re-run this exact prompt) is available everywhere the Stop button
// isn't — including mid-stream on older prompts, since the action interrupts
// the live turn before rewinding.
const showRestore = !readOnly && !showStop && Boolean(onRequestRestoreConfirm) && hasBody
const bubbleClassName = cn(
USER_BUBBLE_BASE_CLASS,
'cursor-pointer pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors',
'border-(--ui-stroke-tertiary) hover:border-(--ui-stroke-secondary)'
)
const bubbleContent = hasBody && (
// Render the user's text through a minimal markdown pipeline:
// backtick `code` and ``` fenced ``` blocks, with directive chips
// (`@file:` etc.) still resolved inside the plain-text spans.
<div
className={cn(clampActive && 'sticky-human-clamp')}
data-clamped={clampActive && bodyClamped ? 'true' : undefined}
>
{/* Match the edit composer's collapsed line box (min-h-[1.25rem]) so
clicking to edit can't grow the bubble by a sub-pixel and reflow the
turn 1px. */}
<div className="min-h-[1.25rem]" ref={clampInnerRef}>
<UserMessageText className="wrap-anywhere" text={messageText} />
</div>
</div>
)
return (
<MessagePrimitive.Root asChild>
<StickyHumanMessageContainer
attachments={
// Attachments live BELOW the sticky bubble in normal flow, so they
// scroll away behind the pinned bubble instead of riding along with
// it. Image refs render as thumbnails, file refs as chips; no border.
attachmentRefs.length > 0 ? (
<div className="flex flex-wrap gap-1 -mt-3 mb-2">
<DirectiveContent text={attachmentRefs.join(' ')} />
</div>
) : null
}
messageId={messageId}
>
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
<ReactionPicker
onOpenChange={setPickerOpen}
onSelect={pickEmoji}
open={pickerOpen}
selected={shownReactions.find(reaction => reaction.author === 'user')?.emoji}
>
<div
className="relative w-full"
// The app context menu skips PLAIN right-clicks here (the
// attr below) so this handler keeps the picker gesture; a
// link/image/selection inside the bubble still gets the app
// menu, and this handler's selection guard keeps ⌘C flows.
data-context-menu-skip=""
onContextMenu={
// Right-click is the desktop stand-in for iOS touch-and-hold —
// but only when there's nothing selected. A live highlight
// keeps the native Copy menu (and ⌘C) instead of the picker.
readOnly || !reactionsEnabled
? undefined
: event => {
if (hasTextSelection()) {
return
}
event.preventDefault()
setPickerOpen(true)
}
}
>
{readOnly ? (
// Spectator transcript: clicking only toggles the clamp so the
// full prompt is readable — never opens an edit composer.
<button
aria-expanded={bodyClamped ? expanded : undefined}
className={cn(bubbleClassName, !bodyClamped && 'cursor-default')}
onClick={() => {
// Drag-select ends on mouseup→click; don't collapse the
// clamp just because the highlight finished.
if (hasTextSelection() || !bodyClamped) {
return
}
triggerHaptic('selection')
setExpanded(value => !value)
}}
title={bodyClamped ? (expanded ? t.common.collapse : copy.expandMessage) : undefined}
type="button"
>
{bubbleContent}
</button>
) : (
// Always editable — clicking opens the edit composer even while a
// turn streams; sending the edit reverts (interrupt + rewind).
// A live text highlight wins: finishing a drag-select must not
// open the editor and throw the selection away.
<ActionBarPrimitive.Edit asChild>
<button
aria-label={copy.editMessage}
className={bubbleClassName}
onClick={event => {
if (hasTextSelection()) {
event.preventDefault()
event.stopPropagation()
return
}
triggerHaptic('selection')
}}
onPointerDown={() => {
if (hasTextSelection()) {
return
}
notifyThreadEditOpen()
}}
type="button"
>
{bubbleContent}
</button>
</ActionBarPrimitive.Edit>
)}
{(showStop || showRestore) && (
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
{showStop ? (
<button
aria-label={copy.stop}
className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)}
onClick={event => {
event.preventDefault()
event.stopPropagation()
void onCancel?.()
}}
title={copy.stop}
type="button"
>
{StopGlyph}
</button>
) : (
<button
aria-label={copy.restoreCheckpoint}
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
onClick={event => {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onRequestRestoreConfirm?.(messageId, {
text: messageText,
userOrdinal: runtimeUserOrdinal
})
}}
onPointerDown={event => {
event.preventDefault()
event.stopPropagation()
}}
title={copy.restoreFromHere}
type="button"
>
<Codicon name="discard" size="0.875rem" />
</button>
)}
</div>
)}
</div>
</ReactionPicker>
{/* Below the bubble, same register as the assistant action row:
same emoji size, same vertical padding, right-aligned to the
sent bubble. Overlaying the corner read badly in practice. */}
<ReactionBadge
className="justify-end gap-1.5 py-1.5 pr-1.5"
onRetract={() => react(null)}
reactions={shownReactions}
/>
<MessageTimelineTimestamp className="self-end pr-1.5" />
<BranchPickerPrimitive.Root
className={cn(
'checkpoint-container flex items-center gap-1 pb-0 pt-1 pl-1.5 text-[0.75rem] leading-none text-(--ui-text-tertiary)',
readOnly && 'hidden'
)}
hideWhenSingleBranch
>
<span aria-hidden className="checkpoint-icon size-1.5 rounded-full border border-current" />
<BranchPickerPrimitive.Previous
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
title={copy.restorePrevious}
>
{copy.restoreCheckpoint}
</BranchPickerPrimitive.Previous>
<span className="checkpoint-divider opacity-55">
<BranchPickerPrimitive.Number />/<BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
title={copy.restoreNext}
>
{copy.goForward}
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
</div>
</ActionBarPrimitive.Root>
</StickyHumanMessageContainer>
</MessagePrimitive.Root>
)
}