Merge pull request #72893 from NousResearch/bb/desktop-activity-grouping

Collapse a turn's tool activity into one grouped, live-ticking line
This commit is contained in:
brooklyn! 2026-07-27 20:11:28 -05:00 committed by GitHub
commit fe71e32a87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 2081 additions and 562 deletions

View file

@ -29,6 +29,7 @@ import { $sessionStates, sessionTileDelegate } from '@/store/session-states'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { clearSessionSubagents } from '@/store/subagents'
import { clearSessionTodos } from '@/store/todos'
import { setSessionDraftingTool } from '@/store/tool-drafting'
import type { SessionInfo } from '@/types/hermes'
import { uploadComposerAttachment } from '../session/hooks/use-prompt-actions'
@ -253,6 +254,7 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
clearSessionTodos(sessionId)
clearSessionSubagents(sessionId)
resetSessionBackground(sessionId)
setSessionDraftingTool(sessionId, '')
clearAllPrompts(sessionId)
clearClarifyRequest(undefined, sessionId)

View file

@ -50,6 +50,7 @@ import {
import { pruneDelegateFallbackSubagents, pruneFinishedSessionSubagents, upsertSubagent } from '@/store/subagents'
import { clearActiveSessionTodos } from '@/store/todos'
import { recordToolDiff } from '@/store/tool-diffs'
import { setSessionDraftingTool } from '@/store/tool-drafting'
import { reportInstallMethodWarning } from '@/store/updates'
import { notifyWorkspaceChanged, toolChangedPath, toolMayMutateFiles } from '@/store/workspace-events'
// Leaf import (not the `@/themes` barrel) to avoid pulling the ThemeProvider
@ -105,6 +106,28 @@ function surfaceBillingBlock(sessionId: string, raw: unknown): void {
})
}
/**
* Events that retire a "drafting a tool call" claim.
*
* `tool.generating` opens the claim and nothing closes it a draft can be
* abandoned without ever reaching `tool.start`, so enumerating the ways one
* *ends* left the label on screen for the rest of the turn. Inverted: the
* claim only covers what the model is emitting right now, and any other output
* from the session proves it moved on. Same rule the TUI applies to its
* transient trail lines (`turnController.pruneTransient`).
*/
const DRAFT_SUPERSEDING_EVENT_TYPES = new Set([
'error',
'message.complete',
'message.delta',
'message.start',
'reasoning.delta',
'thinking.delta',
'tool.complete',
'tool.progress',
'tool.start'
])
const COMPACTION_RESUME_EVENT_TYPES = new Set([
'message.delta',
'message.interim',
@ -243,6 +266,10 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
setSessionCompacting(sessionId, false)
}
if (sessionId && DRAFT_SUPERSEDING_EVENT_TYPES.has(event.type)) {
setSessionDraftingTool(sessionId, '')
}
if (event.type === 'gateway.ready') {
// Seed the active skin into the desktop theme registry without applying,
// so a fresh connect never overrides the user's persisted desktop theme.
@ -677,7 +704,26 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
if (storedId && nextTitle) {
setSessions(prev => prev.map(s => (sessionMatchesStoredId(s, storedId) ? { ...s, title: nextTitle } : s)))
}
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
} else if (event.type === 'tool.generating') {
// Announced while the model is still emitting the call's JSON, so it
// carries a name and nothing else — no id, no args. Materializing a row
// from it strands an argless placeholder whenever the bubble is sealed
// before the real `tool.start` arrives, because the two can no longer be
// reconciled across the boundary. It's a status, so say it as one.
// A stopped turn can still emit a frame or two before the backend
// notices, and naming a tool we will never run leaves the label up
// until something else retires it. `mutateStream` drops late tool rows
// on the same condition; the status line has to agree with it.
if (!sessionId || sessionInterrupted(sessionId)) {
return
}
setSessionDraftingTool(sessionId, typeof payload?.name === 'string' ? payload.name : '')
if (isActiveEvent) {
setPetActivity({ reasoning: false, toolRunning: true })
}
} else if (event.type === 'tool.start' || event.type === 'tool.progress') {
if (!sessionId) {
return
}

View file

@ -0,0 +1,124 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $draftingToolSessions } from '@/store/tool-drafting'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
const SID = 'session-1'
const OTHER_SID = 'session-2'
// Module-scoped so a test can seed session state (e.g. interrupted) before the
// handler reads it — `sessionInterrupted` resolves against this map.
const sessionStates = new Map<string, ClientSessionState>()
let handleEvent: ((event: RpcEvent) => void) | null = null
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(sessionStates)
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const next = updater(sessionStates.get(sessionId) ?? createClientSessionState())
sessionStates.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
}
function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}, sessionId = SID) {
act(() => handleEvent!({ payload, session_id: sessionId, type }))
}
function draftedTool(sessionId = SID) {
return $draftingToolSessions.get()[sessionId]?.name
}
describe('drafting-tool label lifecycle', () => {
beforeEach(() => {
handleEvent = null
sessionStates.clear()
$draftingToolSessions.set({})
})
afterEach(() => {
cleanup()
sessionStates.clear()
$draftingToolSessions.set({})
vi.restoreAllMocks()
})
it('names the tool the model is drafting', async () => {
await mountStream()
emit('tool.generating', { name: 'write_file' })
expect(draftedTool()).toBe('write_file')
})
// The label used to be retired only by the events that mean "this tool ran".
// A tool can be abandoned without ever reaching `tool.start` — a mid-stream
// retry drops a partial call, a guardrail-blocked tool skips the lifecycle
// callbacks — and the name then sat on screen for the rest of the turn.
it.each([
['message.delta', { text: 'never mind' }],
['reasoning.delta', { text: 'reconsidering' }],
['thinking.delta', { text: 'reconsidering' }],
['tool.start', { name: 'terminal', tool_id: 'tool-1' }],
['tool.complete', { name: 'terminal', tool_id: 'tool-1' }],
['message.complete', { text: 'done' }],
['error', { message: 'boom' }]
] as const)('retires the label when %s proves the model moved on', async (type, payload) => {
await mountStream()
emit('tool.generating', { name: 'write_file' })
emit(type, payload)
expect(draftedTool()).toBeUndefined()
})
it('leaves another sessions label alone', async () => {
await mountStream()
emit('tool.generating', { name: 'patch' }, OTHER_SID)
emit('tool.generating', { name: 'write_file' })
emit('message.delta', { text: 'moving on' })
expect(draftedTool()).toBeUndefined()
expect(draftedTool(OTHER_SID)).toBe('patch')
})
// A stopped turn can still emit a frame or two before the backend notices.
it('ignores a tool announced after the user hit stop', async () => {
sessionStates.set(SID, { ...createClientSessionState(), interrupted: true })
await mountStream()
emit('tool.generating', { name: 'write_file' })
expect(draftedTool()).toBeUndefined()
})
})

View file

@ -33,6 +33,7 @@ import {
} from '@/store/session'
import { clearSessionSubagents } from '@/store/subagents'
import { clearSessionTodos } from '@/store/todos'
import { setSessionDraftingTool } from '@/store/tool-drafting'
import type {
ClientSessionState,
@ -592,6 +593,7 @@ export function usePromptActions({
clearSessionTodos(sessionId)
clearSessionSubagents(sessionId)
resetSessionBackground(sessionId)
setSessionDraftingTool(sessionId, '')
// Stop ends the turn, so the gateway is no longer blocked on any prompt it
// raised. Drop this session's pending clarify / approval / sudo / secret so
// a dead panel (and the sidebar "needs input" dot) can't linger and accept

View file

@ -9,10 +9,10 @@ import { type ComponentProps, type FC, type ReactNode, useEffect, useRef, useSta
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool/fallback'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { formatElapsed, useElapsedSeconds, useMeasuredDuration } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { DisclosureRow } from '@/components/chat/disclosure-row'
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 { useEnterAnimation } from '@/lib/use-enter-animation'
@ -57,7 +57,9 @@ const ThinkingDisclosure: FC<{
children: ReactNode
messageRunning?: boolean
pending?: boolean
timerKey?: string
// 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, messageRunning = false, pending = false, timerKey }) => {
const { t } = useI18n()
// `null` = no explicit user toggle yet, defer to the streaming default.
@ -66,6 +68,7 @@ const ThinkingDisclosure: FC<{
// explicit toggle wins from then on.
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)
@ -73,6 +76,23 @@ const ThinkingDisclosure: FC<{
const open = userOpen ?? pending
const isPreview = pending && userOpen === null
// 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(() => {
@ -116,27 +136,14 @@ const ThinkingDisclosure: FC<{
return (
<div
className="text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)"
data-conversation-scaffold=""
data-slot="aui_thinking-disclosure"
ref={enterRef}
>
<DisclosureRow onToggle={() => setUserOpen(!open)} open={open}>
<span className="flex min-w-0 items-baseline gap-1.5">
<span
className={cn(
'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--ui-text-secondary)',
pending && 'shimmer text-foreground/55'
)}
>
{t.assistant.thread.thinking}
</span>
{pending && (
<ActivityTimerText
className="text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)"
seconds={elapsed}
/>
)}
</span>
</DisclosureRow>
<ScaffoldRow onToggle={() => setUserOpen(!open)} open={open}>
<span className={cn(SCAFFOLD_LABEL_CLASS, pending && 'shimmer')}>{thoughtLabel}</span>
{pending && <ActivityTimerText className={SCAFFOLD_META_CLASS} seconds={elapsed} />}
</ScaffoldRow>
{open && (
<div
className={cn(
@ -193,7 +200,15 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
}
return (
<ThinkingDisclosure messageRunning={messageRunning} pending={pending} timerKey={`reasoning:${messageId}`}>
// 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
messageRunning={messageRunning}
pending={pending}
timerKey={`reasoning:${messageId}:${startIndex}`}
>
{children}
</ThinkingDisclosure>
)

View file

@ -54,3 +54,18 @@ describe('ResponseLoadingIndicator timer', () => {
expect(screen.getAllByText((_, node) => node?.textContent === '8s').length).toBeGreaterThan(0)
})
})
// 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)
})
})

View file

@ -3,8 +3,10 @@ import { useStore } from '@nanostores/react'
import { type FC, type ReactNode, useEffect, useMemo, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
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 { useI18n } from '@/i18n'
@ -13,7 +15,11 @@ import { $backgroundResume } from '@/store/background-delegation'
import { sessionCompacting } from '@/store/compaction'
import { sessionAwaitingInput } from '@/store/prompts'
import { $turnStartedAt } from '@/store/session'
import { type DraftingTool, sessionDraftingTool } from '@/store/tool-drafting'
// 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,
@ -23,7 +29,12 @@ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentProp
<div
aria-label={label}
aria-live="polite"
className={cn('flex max-w-full items-center gap-2 self-start text-sm text-muted-foreground/70', className)}
className={cn(
'flex 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}
>
@ -34,8 +45,8 @@ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentProp
// Fixed label while auto-compaction runs — decoupled from backend status text.
const COMPACTION_LABEL = 'Summarizing thread'
const CompactionHint: FC = () => (
<span className="shimmer min-w-0 truncate text-muted-foreground/55">{COMPACTION_LABEL}</span>
const HintText: FC<{ children: ReactNode }> = ({ children }) => (
<span className={cn(SCAFFOLD_LABEL_CLASS, 'shimmer min-w-0 truncate')}>{children}</span>
)
/** These indicators render inside whichever transcript mounted them, so every
@ -45,6 +56,7 @@ function useThreadSessionStatus() {
const sessionId = useStore(useSessionView().$runtimeId)
const turnStartedAt = useStore($turnStartedAt)
const compacting = useStore(useMemo(() => sessionCompacting(sessionId), [sessionId]))
const drafting = useStore(useMemo(() => sessionDraftingTool(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).
@ -53,10 +65,42 @@ function useThreadSessionStatus() {
return {
awaitingInput,
compacting,
drafting,
turnTimerKey: sessionId && turnStartedAt ? `turn:${sessionId}:${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): 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
}
return revealed && name ? toolPresentVerb(name) : ''
}
export const CenteredThreadSpinner: FC = () => {
const { t } = useI18n()
@ -80,17 +124,14 @@ export const CenteredThreadSpinner: FC = () => {
export const ResponseLoadingIndicator: FC = () => {
const { t } = useI18n()
const { compacting, turnTimerKey } = useThreadSessionStatus()
const { compacting, drafting, turnTimerKey } = useThreadSessionStatus()
const elapsed = useElapsedSeconds(true, turnTimerKey)
const hint = useStatusHint(compacting, drafting)
return (
<StatusRow
className="text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height)"
data-slot="aui_response-loading"
label={compacting ? COMPACTION_LABEL : t.assistant.thread.loadingResponse}
>
<StatusRow data-slot="aui_response-loading" label={hint || t.assistant.thread.loadingResponse}>
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
{compacting && <CompactionHint />}
{hint && <HintText>{hint}</HintText>}
<ActivityTimerText seconds={elapsed} />
</StatusRow>
)
@ -159,7 +200,13 @@ export const StreamStallIndicator: FC = () => {
// 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, compacting, turnTimerKey } = useThreadSessionStatus()
const { awaitingInput, compacting, drafting, turnTimerKey } = useThreadSessionStatus()
const hint = useStatusHint(compacting, drafting)
// 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.
const toolNarrating = useAuiState(s => s.message.content.at(-1)?.type === 'tool-call')
useEffect(() => {
setQuietSince(undefined)
@ -169,24 +216,28 @@ export const StreamStallIndicator: FC = () => {
return () => window.clearTimeout(id)
}, [activity])
const active = (quietSince !== undefined || compacting) && !awaitingInput
// A named wait doesn't have to earn the stall threshold first — we already
// know what the turn is doing, so say it as soon as the label is ready rather
// than leaving the transcript silent for STREAM_STALL_S.
const active = (quietSince !== undefined || Boolean(hint)) && !awaitingInput && !toolNarrating
// Compaction owns the whole turn, so it keeps counting from the turn's start;
// a plain stall counts from the last thing the stream produced.
const elapsed = useElapsedSeconds(active, compacting ? turnTimerKey : undefined, compacting ? undefined : quietSince)
// anything else counts from the moment the stream went quiet — the stall's own
// mark, or the draft's, whichever named the wait first.
const elapsed = useElapsedSeconds(
active,
compacting ? turnTimerKey : undefined,
compacting ? undefined : (quietSince ?? drafting?.since)
)
if (!active) {
return null
}
return (
<StatusRow
className="mt-1.5"
data-slot="aui_stream-stall"
label={compacting ? COMPACTION_LABEL : 'Hermes is thinking'}
>
<StatusRow data-slot="aui_stream-stall" label={hint || 'Hermes is thinking'}>
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
{compacting && <CompactionHint />}
{hint && <HintText>{hint}</HintText>}
<ActivityTimerText seconds={elapsed} />
</StatusRow>
)

View file

@ -606,7 +606,8 @@ describe('assistant-ui streaming renderer', () => {
const { container } = render(<ReasoningHarness />)
const ui = within(container)
fireEvent.click(ui.getByRole('button', { name: /thinking/i }))
// 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.'

View file

@ -1,299 +0,0 @@
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 { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
import { $activeSessionId } from '@/store/session'
import { clearDismissedToolRows } from '@/store/tool-dismiss'
import { $toolDisclosureStates } from '@/store/tool-view'
import { Thread } from '../thread'
// Regression coverage for the "approval must never be buried" bug. Tools now
// render as a flat list (no collapsible "N steps" group), so a pending tool's
// inline ApprovalBar is always in the visual flow — never inside a `hidden`
// body. These assert the bar shows only when an approval is live and is never
// trapped under a `hidden` ancestor.
const createdAt = new Date('2026-06-03T00: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)
}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
Element.prototype.scrollTo = function scrollTo() {}
Element.prototype.animate = function animate() {
return {
cancel: () => {},
finished: Promise.resolve()
} as unknown as Animation
}
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
fallback: number
) {
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
Object.defineProperty(HTMLElement.prototype, prop, {
configurable: true,
get() {
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
}
})
}
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
// A running assistant message with two tools: a completed read_file plus a
// pending terminal (no result), rendered as a flat two-row list.
function groupedPendingMessage(): ThreadMessage {
return {
id: 'assistant-group-1',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'read-1',
toolName: 'read_file',
args: { path: '/etc/hosts' },
argsText: JSON.stringify({ path: '/etc/hosts' }),
result: { content: '127.0.0.1 localhost' }
},
{
type: 'tool-call',
toolCallId: 'term-1',
toolName: 'terminal',
args: { command: 'rm -rf /tmp/x' },
argsText: JSON.stringify({ command: 'rm -rf /tmp/x' })
}
],
status: { type: 'running' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function pendingOnlyMessage(): ThreadMessage {
return {
id: 'assistant-pending-only',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'term-only',
toolName: 'terminal',
args: { command: 'sleep 10' },
argsText: JSON.stringify({ command: 'sleep 10' })
}
],
status: { type: 'running' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function completedOnlyMessage(): ThreadMessage {
return {
id: 'assistant-completed-only',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'read-only',
toolName: 'read_file',
args: { path: '/etc/hosts' },
argsText: JSON.stringify({ path: '/etc/hosts' }),
result: { content: '127.0.0.1 localhost' }
}
],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function failedOnlyMessage(): ThreadMessage {
return {
id: 'assistant-failed-only',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'term-failed',
toolName: 'terminal',
args: { command: 'exit 1' },
argsText: JSON.stringify({ command: 'exit 1' }),
isError: true,
result: { stderr: 'boom' }
}
],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function GroupHarness({ message }: { message: ThreadMessage }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
isRunning: message.status?.type === 'running',
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
beforeEach(() => {
clearAllPrompts()
$activeSessionId.set('sess-1')
$toolDisclosureStates.set({})
clearDismissedToolRows()
})
afterEach(() => {
cleanup()
clearAllPrompts()
$activeSessionId.set(null)
clearDismissedToolRows()
})
describe('flat tool list approval surfacing', () => {
it('renders no inline approval bar when there is no live approval', async () => {
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
// The pending terminal row mounts immediately, but its inline ApprovalBar
// returns null while $approvalRequest is empty.
await waitFor(() => {
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
})
expect(container.querySelector('[data-slot="tool-approval-inline"]')).toBeNull()
})
it('surfaces the approval inline and never under a hidden ancestor', async () => {
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
await waitFor(() => {
const bar = container.querySelector('[data-slot="tool-approval-inline"]')
expect(bar).not.toBeNull()
// Flat rows live directly in the flow — nothing should ever wrap the bar
// in a `hidden` subtree.
expect(bar?.closest('[hidden]')).toBeNull()
})
})
it('lets completed tool rows be dismissed', async () => {
const { container } = render(<GroupHarness message={completedOnlyMessage()} />)
const dismiss = await screen.findByLabelText('Dismiss')
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(1)
fireEvent.click(dismiss)
await waitFor(() => {
expect(screen.queryByLabelText('Dismiss')).toBeNull()
})
})
it('keeps a dismissed row hidden after a remount (virtualization)', async () => {
// The thread virtualizes, so a row's component unmounts/remounts as it
// scrolls. Dismissal must persist across that — component-local state would
// forget it and the row would pop back. Simulate the remount by unmounting
// and rendering the same message fresh.
const first = render(<GroupHarness message={completedOnlyMessage()} />)
fireEvent.click(await screen.findByLabelText('Dismiss'))
await waitFor(() => {
expect(screen.queryByLabelText('Dismiss')).toBeNull()
})
first.unmount()
const { container } = render(<GroupHarness message={completedOnlyMessage()} />)
await waitFor(() => {
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
})
expect(screen.queryByLabelText('Dismiss')).toBeNull()
})
it('lets failed tool rows be dismissed', async () => {
render(<GroupHarness message={failedOnlyMessage()} />)
const dismiss = await screen.findByLabelText('Dismiss')
fireEvent.click(dismiss)
await waitFor(() => {
expect(screen.queryByLabelText('Dismiss')).toBeNull()
})
})
it('does not show dismiss for pending tool rows', async () => {
const { container } = render(<GroupHarness message={pendingOnlyMessage()} />)
await waitFor(() => {
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
})
expect(screen.queryByLabelText('Dismiss')).toBeNull()
})
})

View file

@ -65,7 +65,7 @@ function fileEditPath(args: Record<string, unknown>, result: Record<string, unkn
)
}
function fileEditBasename(path: string): string {
export function fileEditBasename(path: string): string {
const normalized = path.replace(/\\/g, '/').trim()
return normalized.split('/').filter(Boolean).pop() || normalized
@ -585,7 +585,7 @@ function summarizeBrowserSnapshot(snapshot: string): string {
return labels.length ? `${stats}\nTop controls: ${labels.join(', ')}` : stats
}
function firstStringField(record: Record<string, unknown>, keys: readonly string[]): string {
export function firstStringField(record: Record<string, unknown>, keys: readonly string[]): string {
for (const key of keys) {
const value = record[key]

View file

@ -1,42 +1,56 @@
import { describe, expect, it } from 'vitest'
import { isUnboundableTool, shouldBoundToolGroup, technicalTrace } from './fallback'
import { isCardTool, splitRunItems, technicalTrace } from './fallback'
describe('shouldBoundToolGroup', () => {
it('bounds long runs of ordinary tool calls', () => {
expect(shouldBoundToolGroup(3, false)).toBe(true)
})
it('leaves short runs unbounded', () => {
expect(shouldBoundToolGroup(2, false)).toBe(false)
})
it('never bounds a run holding an unboundable tool', () => {
expect(shouldBoundToolGroup(3, true)).toBe(false)
})
})
describe('isUnboundableTool', () => {
it('exempts clarify forms and generated images from the window', () => {
expect(isUnboundableTool('clarify')).toBe(true)
expect(isUnboundableTool('image_generate')).toBe(true)
})
// Everything ToolEntry renders carries `data-tool-row`, so the
// `:has([data-tool-row][data-tool-open])` rule in styles.css lifts the cap
// on its own. A diff row mounts open and frees the group immediately; a
// collapsed row has no body in the DOM to clip. Exempting these in JS
// instead vetoed grouping for the whole run — and since reads and edits are
// most of a coding session, runs of 19 calls never collapsed at all.
it('bounds the rows the CSS break-out already covers', () => {
for (const toolName of ['read_file', 'execute_code', 'edit_file', 'patch', 'write_file']) {
expect(isUnboundableTool(toolName)).toBe(false)
describe('isCardTool', () => {
it('keeps what the user has to look at out of a summary', () => {
// A diff is the deliverable, a clarify is a question waiting on an answer,
// an image is the thing that was asked for. None of them survives being
// folded into "used 3 tools".
for (const toolName of ['clarify', 'image_generate', 'edit_file', 'patch', 'write_file']) {
expect(isCardTool(toolName)).toBe(true)
}
})
it('still bounds console output and other ordinary rows', () => {
expect(isUnboundableTool('terminal')).toBe(false)
expect(isUnboundableTool('web_search')).toBe(false)
it('treats reads, searches and commands as ephemeral activity', () => {
for (const toolName of ['read_file', 'search_files', 'terminal', 'execute_code', 'web_search']) {
expect(isCardTool(toolName)).toBe(false)
}
})
})
describe('splitRunItems', () => {
it('collapses a stretch of activity into one run', () => {
expect(splitRunItems(['read_file', 'search_files', 'terminal'])).toEqual([{ end: 2, kind: 'run', start: 0 }])
})
it('keeps a card at the point in the turn where it happened', () => {
// Read, edit, read has to stay in that order — a summary, the diff, then a
// second summary — rather than sorting the diffs to one end.
expect(splitRunItems(['read_file', 'patch', 'read_file', 'terminal'])).toEqual([
{ end: 0, kind: 'run', start: 0 },
{ index: 1, kind: 'card' },
{ end: 3, kind: 'run', start: 2 }
])
})
it('does not let adjacent cards merge into a run', () => {
expect(splitRunItems(['patch', 'write_file'])).toEqual([
{ index: 0, kind: 'card' },
{ index: 1, kind: 'card' }
])
})
it('passes a part that is not a tool call through as its own card', () => {
expect(splitRunItems(['read_file', '', 'read_file'])).toEqual([
{ end: 0, kind: 'run', start: 0 },
{ index: 1, kind: 'card' },
{ end: 2, kind: 'run', start: 2 }
])
})
it('has nothing to split when the range is empty', () => {
expect(splitRunItems([])).toEqual([])
})
})

View file

@ -5,10 +5,12 @@ import { useStore } from '@nanostores/react'
import {
Children,
createContext,
type CSSProperties,
type FC,
Fragment,
isValidElement,
type PropsWithChildren,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
@ -23,6 +25,7 @@ import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { CompactMarkdown } from '@/components/chat/compact-markdown'
import { FileDiffPanel } from '@/components/chat/diff-lines'
import { DisclosureRow } from '@/components/chat/disclosure-row'
import { SCAFFOLD_LABEL_CLASS, SCAFFOLD_META_CLASS, ScaffoldRow } from '@/components/chat/scaffold-row'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@ -40,11 +43,12 @@ import { normalize } from '@/lib/text'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { recordPreviewArtifact } from '@/store/preview-status'
import { sessionApprovalRequest } from '@/store/prompts'
import { $toolInlineDiff } from '@/store/tool-diffs'
import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss'
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
import { PendingToolApproval } from './approval'
import { APPROVAL_TOOLS, PendingToolApproval } from './approval'
import {
buildToolView,
clampForDisplay,
@ -63,6 +67,7 @@ import {
type ToolStatus,
type ToolTitleAction
} from './fallback-model'
import { isToolCallPart, summarizeToolRun } from './run-summary'
// `true` when a ToolEntry is rendered inside an embedding wrapper that owns
// the per-row chrome (timer / preview). The flat ToolGroupSlot sets this
@ -70,14 +75,11 @@ import {
// future embedding surface.
const ToolEmbedContext = createContext(false)
// Shared header chrome for tool rows. Both the single-tool DisclosureRow
// and the multi-tool group header pass through these constants so a
// "Patch" row and a "Tool actions · 2 steps" row are visually identical.
const TOOL_HEADER_TITLE_CLASS =
// A search hit's title is result *content* inside an expanded row, not one of
// the scaffolding lines, so it keeps the brighter secondary grey.
const SEARCH_HIT_TITLE_CLASS =
'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--ui-text-secondary)'
const TOOL_HEADER_DURATION_CLASS = 'shrink-0 text-[0.625rem] tabular-nums text-(--ui-text-tertiary)'
const TOOL_HEADER_SUBTITLE_CLASS =
'text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)'
@ -252,13 +254,13 @@ function SearchResultsList({ hits }: { hits: SearchResultRow[] }) {
<li className="grid min-w-0 gap-0.5" key={key}>
{hit.url ? (
<PrettyLink
className={cn(TOOL_HEADER_TITLE_CLASS, 'block max-w-full')}
className={cn(SEARCH_HIT_TITLE_CLASS, 'block max-w-full')}
fallbackLabel={trimmedTitle || urlSlugTitleLabel(hit.url)}
href={hit.url}
label={trimmedTitle || undefined}
/>
) : (
<span className={TOOL_HEADER_TITLE_CLASS}>{trimmedTitle}</span>
<span className={SEARCH_HIT_TITLE_CLASS}>{trimmedTitle}</span>
)}
{hit.snippet && <p className={cn(TOOL_HEADER_SUBTITLE_CLASS, 'm-0 line-clamp-3')}>{hit.snippet}</p>}
</li>
@ -286,8 +288,8 @@ function ToolTitle({
return (
<FadeText
className={cn(
TOOL_HEADER_TITLE_CLASS,
isPending && 'text-(--ui-text-tertiary)',
SCAFFOLD_LABEL_CLASS,
isPending && 'text-(--conversation-scaffold-meta)',
status === 'error' && 'text-destructive',
status === 'warning' && 'text-amber-700 dark:text-amber-300'
)}
@ -460,7 +462,7 @@ function ToolEntry({ part }: ToolEntryProps) {
// the disclosure caret hard to hit. Copy now lives in the expanded body's
// top-right, where it can't fight the caret for the right edge.
const trailing =
isPending && !embedded ? <ActivityTimerText className={TOOL_HEADER_DURATION_CLASS} seconds={elapsed} /> : undefined
isPending && !embedded ? <ActivityTimerText className={SCAFFOLD_META_CLASS} seconds={elapsed} /> : undefined
// Once a turn has settled, a hover/focus-revealed dismiss lets the user clear
// a completed/failed row that would otherwise sit at the tail of the chat.
@ -508,6 +510,7 @@ function ToolEntry({ part }: ToolEntryProps) {
'group/tool-block min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)',
open && TOOL_EXPANDED_SHELL_CLASS
)}
data-conversation-scaffold=""
data-file-edit={isFileEdit && open ? '' : undefined}
data-slot="tool-block"
data-tool-open={open ? '' : undefined}
@ -532,7 +535,7 @@ function ToolEntry({ part }: ToolEntryProps) {
status={leadingStatus(isPending, view.status)}
/>
<ToolTitle isPending={isPending} status={view.status} title={view.title} titleAction={view.titleAction} />
{!isPending && view.countLabel && <span className={TOOL_HEADER_DURATION_CLASS}>{view.countLabel}</span>}
{!isPending && view.countLabel && <span className={SCAFFOLD_META_CLASS}>{view.countLabel}</span>}
{showDiffStats && diffStats && (
<span className="flex shrink-0 items-center gap-1 font-mono text-[0.625rem] tabular-nums">
{diffStats.added > 0 && (
@ -544,7 +547,7 @@ function ToolEntry({ part }: ToolEntryProps) {
</span>
)}
{!isFileEdit && !isPending && view.durationLabel && (
<span className={TOOL_HEADER_DURATION_CLASS}>{view.durationLabel}</span>
<span className={SCAFFOLD_META_CLASS}>{view.durationLabel}</span>
)}
</span>
</DisclosureRow>
@ -699,155 +702,260 @@ function TerminalTranscript({ command, exitCode }: TerminalTranscriptProps) {
)
}
// A back-to-back run of this many tool calls collapses into the bounded,
// auto-scrolling window; fewer than this stays a plain inline stack.
const TOOL_GROUP_SCROLL_THRESHOLD = 3
// Tools whose body must never be trapped behind the window's max-height +
// fade mask. A run holding any of them stays a plain, fully-visible stack no
// matter how long it is.
// Tools that draw their own surface and must never be folded into a run's
// summary. Two kinds, for the same reason — the thing on screen IS the point:
//
// This list is deliberately tiny. A row rendered by ToolEntry carries
// `data-tool-row`, and the `:has([data-tool-row][data-tool-open])` rule in
// styles.css lifts the cap whenever one is open — so anything ToolEntry
// renders takes care of itself. A code/diff row mounts open (see
// `defaultOpen`), lifting the cap the moment it appears; a collapsed row is a
// one-line status with no body in the DOM at all, so there is nothing to clip.
// - File edits are the deliverable, not scaffolding. The diff is what the
// user reviews, so it stays visible at its place in the turn, live and
// settled, the way a PR shows its changes.
// - `clarify` and `image_generate` bypass ToolEntry to render their own
// markup: a question the user has to answer, an image they asked for.
//
// Only components that bypass ToolEntry need this opt-out: `clarify` and
// `image_generate` render their own markup, never emit `data-tool-row`, and so
// the CSS escape hatch can never reach them.
const UNBOUNDABLE_TOOLS = new Set(['clarify', 'image_generate'])
// Everything else is ephemeral activity — reads, searches, commands — which is
// what a run summarizes and what the live ticker cycles through.
const CARD_TOOLS = new Set(['clarify', 'image_generate'])
export function isUnboundableTool(toolName: string): boolean {
return UNBOUNDABLE_TOOLS.has(toolName)
export function isCardTool(toolName: string): boolean {
return CARD_TOOLS.has(toolName) || isFileEditTool(toolName)
}
export function shouldBoundToolGroup(childCount: number, hasUnboundable: boolean) {
return childCount >= TOOL_GROUP_SCROLL_THRESHOLD && !hasUnboundable
}
export type RunItem = { end: number; kind: 'run'; start: number } | { index: number; kind: 'card' }
// Pin-to-bottom + top-fade for the bounded tool window. Pins the newest row on
// growth (a call lands or a row expands) unless the user scrolled up, and fades
// the top edge once anything sits above it. Mirrors ThinkingDisclosure's live
// preview. `enabled` is false for short runs, leaving the plain flat stack.
function useToolWindow(enabled: boolean) {
const scrollRef = useRef<HTMLDivElement | null>(null)
const contentRef = useRef<HTMLDivElement | null>(null)
const stickRef = useRef(true)
const [faded, setFaded] = useState(false)
/**
* Split a range of parts into cards and the runs of activity between them.
*
* Order is preserved rather than sorted into "all the runs, then all the
* cards": a turn that reads, edits, then reads again shows a summary, the
* diff, then a second summary, in the sequence it happened. Indices are
* relative to the range. An empty name is a part that isn't a tool call at
* all, which passes through as its own card.
*/
export function splitRunItems(toolNames: readonly string[]): RunItem[] {
const items: RunItem[] = []
let run: null | Extract<RunItem, { kind: 'run' }> = null
const syncFade = useCallback(() => setFaded((scrollRef.current?.scrollTop ?? 0) > 4), [])
toolNames.forEach((name, index) => {
if (!name || isCardTool(name)) {
run = null
items.push({ index, kind: 'card' })
const onScroll = useCallback(() => {
const el = scrollRef.current
if (!el) {
return
}
stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight <= 8
syncFade()
}, [syncFade])
useEffect(() => {
const el = scrollRef.current
const content = contentRef.current
if (!enabled || !el || !content) {
return
if (run) {
run.end = index
} else {
run = { end: index, kind: 'run', start: index }
items.push(run)
}
})
// Track the content's HEIGHT and only pin when it grows. The observer also
// fires for width changes — a sidebar sash drag resizes every tool window
// once per frame — and pinning there is (a) pointless, the list didn't
// grow, and (b) expensive: `pin` writes scrollTop then `syncFade` reads it
// back, a write->read forced reflow per tool group per frame. Measured on
// a real session while dragging the sash: 927ms of `pin` script plus
// 2.7s of style recalc across one 60-frame drag. Reading the height off
// the RO entry keeps the check 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) {
return
}
if (stickRef.current) {
el.scrollTop = el.scrollHeight
}
syncFade()
}
// No sync pin() here: the observer's guaranteed initial delivery runs it
// inside RO timing (layout already clean, still before paint). A sync call
// at effect time reads scrollHeight while the commit's layout is dirty —
// one forced reflow per tool group, which cascades on a session switch.
const observer = new ResizeObserver(pin)
observer.observe(content)
return () => observer.disconnect()
}, [enabled, syncFade])
return { contentRef, faded, onScroll, scrollRef }
return items
}
/**
* Flat, Cursor-style tool list. assistant-ui hands us a *range* of
* consecutive tool-call parts, but how that range is sliced is unstable: a
* live stream interleaves narration/reasoning between calls (many tiny
* ranges), while the settled message reconstructs every tool_call back-to-back
* (one big range). Rendering a "Tool actions · N steps" group off that range
* therefore reshuffled the whole turn the instant it settled.
* The live run, as one line.
*
* So we still never *label* the group: each tool is a standalone row on the
* tight `--tool-row-gap` rhythm. Once a run reaches `TOOL_GROUP_SCROLL_THRESHOLD`
* rows it collapses into a fixed-height, auto-scrolling window so a long run
* doesn't shove the reply off screen; shorter runs are byte-identical to before.
* The DOM shape is the same either way only classes flip so a run that
* crosses the threshold mid-stream never remounts a row. `ToolEmbedContext` is
* false so every row owns its own chrome (timer / preview / copy / approval).
* A run in progress shows only what it is doing right now; each new action
* slides the one before it up and out of a single-line window, so a turn that
* touches thirty files reads as one line ticking over in place instead of a
* list growing down the page. When the run settles the ticker goes away and
* the summary above it is all that's left.
*
* Rows are clipped to a uniform line box so the reel's offset stays exact
* whatever a row happens to contain.
*/
function ToolRunTicker({ children }: { children: ReactNode }) {
const rows = Children.toArray(children)
return (
<div className="tool-ticker" data-tool-ticker="">
<div className="tool-ticker__reel" style={{ '--tool-ticker-index': rows.length - 1 } as CSSProperties}>
{rows.map((row, index) => (
<div className="tool-ticker__row" key={isValidElement(row) ? (row.key ?? index) : index}>
{row}
</div>
))}
</div>
</div>
)
}
// The one grey line that stands in for a run of tool calls — "Explored 3
// files, ran 5 commands". Live, it narrates in the present tense above the
// ticker and offers no toggle, since there is nothing settled to unfold yet.
function ToolRunHeader({
live,
onToggle,
open,
summary
}: {
live: boolean
onToggle?: () => void
open: boolean
summary: string
}) {
return (
<div data-conversation-scaffold="" data-tool-summary="">
<ScaffoldRow onToggle={onToggle} open={open}>
<FadeText className={cn(SCAFFOLD_LABEL_CLASS, 'truncate')}>
{live ? <span className="shimmer">{summary}</span> : summary}
</FadeText>
</ScaffoldRow>
</div>
)
}
interface ToolRunState {
count: number
key: string
live: boolean
/** A call still awaiting a result that could be the one blocking on approval. */
pendingApprovalTool: boolean
summary: string
}
// assistant-ui compares selector results with `Object.is` and calls the
// selector on every store update, so returning a fresh object here would
// re-render the group on every text delta in the turn. The run only changes
// when a call arrives or one finishes; cache on exactly that.
function useToolRun(startIndex: number, endIndex: number): ToolRunState {
const cache = useRef<null | { signature: string; value: ToolRunState }>(null)
return useAuiState(state => {
const parts = state.message.parts
const tools = parts.slice(Math.max(0, startIndex), endIndex + 1).filter(isToolCallPart)
// Live means the turn is still working and nothing has come after this run
// — not that some call is unresolved. Those differ in the gap between one
// call finishing and the next arriving, which for sequential calls is most
// of the run: it fell back to past tense there, unmounting the ticker and
// dropping its reel to the top instead of scrolling.
//
// The tail bound is what keeps this honest — a turn that ends, or an agent
// that moves on to later parts, leaves the run settled and collapsible.
const live = selectMessageRunning(state) && endIndex >= parts.length - 1
const signature = tools
.map(tool => `${tool.toolCallId}:${tool.result === undefined ? 0 : 1}`)
.concat(String(live))
.join('|')
if (cache.current?.signature !== signature) {
cache.current = {
signature,
value: {
count: tools.length,
key: tools[0]?.toolCallId ?? '',
live,
pendingApprovalTool: tools.some(tool => tool.result === undefined && APPROVAL_TOOLS.has(tool.toolName)),
summary: summarizeToolRun(tools, live)
}
}
}
return cache.current.value
})
}
/**
* One run of consecutive activity calls, headed by the line that summarizes it.
*
* The run is identified by its FIRST tool call, never by its position: a live
* stream and the same turn rehydrated from history agree on which calls belong
* together, but not on the indices they land at, because rehydration folds a
* turn into one bubble that the live view spreads over several. Keying off the
* index is what made an earlier attempt at this reshuffle the moment a turn
* settled. `lib/tool-run-continuity.test.ts` locks that agreement down.
*
* Live, the run is a summary plus the one-line ticker. Settled, the summary is
* the whole of it until the user opens it. `ToolEmbedContext` is false so each
* row still owns its own chrome (timer / copy / approval) when shown.
*/
const ToolRun: FC<PropsWithChildren<{ endIndex: number; startIndex: number }>> = ({
children,
endIndex,
startIndex
}) => {
const messageRunning = useAuiState(selectMessageRunning)
const { count, key, live, pendingApprovalTool, summary } = useToolRun(startIndex, endIndex)
const sessionId = useStore(useSessionView().$runtimeId)
const approval = useStore(useMemo(() => sessionApprovalRequest(sessionId), [sessionId]))
const disclosureId = `tool-run:${key}`
const persistedOpen = useStore($toolDisclosureOpen(disclosureId))
const enterRef = useEnterAnimation(messageRunning, `tool-run:${key}`)
// A lone call is already its own one-line summary; heading it with a second
// line would say the same thing twice.
if (count < 2) {
return <>{children}</>
}
// An approval is a question the user has to answer, and the ticker only ever
// shows one line — the row carrying the approval bar could tick straight
// past it. Show the whole run until it's answered.
const blocked = Boolean(approval) && pendingApprovalTool
const expanded = live ? blocked : (persistedOpen ?? false)
return (
<div
className="grid min-w-0 max-w-full gap-(--tool-row-gap) overflow-hidden"
data-slot="tool-block"
data-tool-group=""
ref={enterRef}
>
<ToolRunHeader
live={live}
onToggle={live ? undefined : () => setToolDisclosureOpen(disclosureId, !expanded)}
open={expanded}
summary={summary}
/>
{live && !blocked && <ToolRunTicker>{children}</ToolRunTicker>}
{expanded && <div className="grid min-w-0 max-w-full gap-(--tool-row-gap)">{children}</div>}
</div>
)
}
/**
* A range of consecutive tool calls, split into the cards that must stay on
* screen and the runs of activity between them.
*
* assistant-ui hands the whole adjacent range over as one group; what belongs
* together is a narrower question than adjacency. A diff or a question for the
* user is the point of the turn and renders in place, while the reads and
* commands around it collapse into a line. Splitting here rather than asking
* for different ranges keeps the decision next to the rendering that depends
* on it.
*/
export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex: number }>> = ({
children,
endIndex,
startIndex
}) => {
const messageId = useAuiState(s => s.message.id)
const messageRunning = useAuiState(selectMessageRunning)
const hasUnboundable = useAuiState(s =>
s.message.parts
// Joined rather than returned as an array: assistant-ui compares selector
// results with `Object.is` and re-runs them on every store update, so a
// fresh array would re-render the whole group on every text delta.
const toolNameKey = useAuiState(state =>
state.message.parts
.slice(Math.max(0, startIndex), endIndex + 1)
.some(part => part.type === 'tool-call' && isUnboundableTool(part.toolName))
.map(part => (part.type === 'tool-call' ? part.toolName : ''))
.join('\u0000')
)
const enterRef = useEnterAnimation(messageRunning, `tool-group:${messageId}:${startIndex}`)
const bounded = shouldBoundToolGroup(Children.count(children), hasUnboundable)
const { contentRef, faded, onScroll, scrollRef } = useToolWindow(bounded)
const items = useMemo(() => splitRunItems(toolNameKey.split('\u0000')), [toolNameKey])
const rows = Children.toArray(children)
return (
<ToolEmbedContext.Provider value={false}>
<div className="min-w-0 max-w-full overflow-hidden" data-slot="tool-block" data-tool-group="" ref={enterRef}>
<div
className={cn(
bounded && 'tool-group-scroll max-h-(--tool-group-scroll-max-h) overflow-y-auto',
bounded && faded && 'tool-group-scroll--faded'
)}
onScroll={bounded ? onScroll : undefined}
ref={scrollRef}
>
<div className="grid min-w-0 max-w-full gap-(--tool-row-gap)" ref={contentRef}>
{children}
</div>
</div>
</div>
{items.map(item =>
item.kind === 'card' ? (
<Fragment key={`card:${item.index}`}>{rows[item.index]}</Fragment>
) : (
<ToolRun endIndex={startIndex + item.end} key={`run:${item.start}`} startIndex={startIndex + item.start}>
{rows.slice(item.start, item.end + 1)}
</ToolRun>
)
)}
</ToolEmbedContext.Provider>
)
}

View file

@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { summarizeToolRun, type ToolCallLike } from './run-summary'
function tool(toolName: string, args: Record<string, unknown> = {}, result?: unknown): ToolCallLike {
return { args, result, toolCallId: `${toolName}-${Math.random()}`, toolName }
}
const read = (path: string) => tool('read_file', { path }, { content: '' })
const searched = (query: string) => tool('search_files', { query }, { hits: [] })
const ran = (command: string) => tool('terminal', { command }, { exit_code: 0 })
const settled = (tools: ToolCallLike[]) => summarizeToolRun(tools, false)
const running = (tools: ToolCallLike[]) => summarizeToolRun(tools, true)
// A run only ever holds ephemeral activity: reads, searches, commands. File
// edits and other cards are split out before a run is summarized, so there is
// no "Edited …" clause to test here — that work shows as its own diff card.
describe('summarizeToolRun', () => {
it('names a lone target and counts the rest', () => {
expect(settled([searched('toolRuns'), read('a.ts'), read('b.ts'), read('c.ts')])).toBe('Explored 4 files')
})
it('orders clauses explore then run regardless of call order', () => {
expect(settled([ran('ls'), read('a.ts'), read('b.ts'), ran('pwd'), ran('id')])).toBe(
'Explored 2 files, ran 3 commands'
)
})
it('counts commands rather than naming them once they have run', () => {
expect(settled([ran('git status')])).toBe('Ran 1 command')
expect(settled([read('status.ts'), ran('a'), ran('b'), ran('c'), ran('d'), ran('e')])).toBe(
'Explored status.ts, ran 5 commands'
)
})
it('puts the running category in the present tense and leaves the rest past', () => {
expect(running([read('a.ts'), tool('read_file', { path: 'b.ts' }), ran('x'), ran('y')])).toBe(
'Exploring 2 files, ran 2 commands'
)
})
it('names the command that is still running', () => {
expect(running([tool('terminal', { command: 'npm run typecheck' })])).toMatch(/^Running /)
})
// Sequential calls leave a gap where the run is still going but nothing is
// pending. Falling back to past tense there contradicted the ticker still
// scrolling underneath, so the most recent call carries the present tense.
it('stays in the present tense between two sequential calls', () => {
expect(running([read('a.ts'), ran('x'), ran('y')])).toBe('Explored a.ts, running 2 commands')
})
// A turn can end — or the agent can simply move on — with a call that never
// got a result. The run is history at that point and has to read as history,
// or it narrates work that stopped happening and never offers its toggle.
it('reads a run the turn left unresolved as finished', () => {
expect(settled([read('a.ts'), tool('search_files', { query: 'toolRuns' })])).toBe('Explored 2 files')
})
})

View file

@ -0,0 +1,156 @@
import { summarizeShellCommand } from '@/lib/summarize-command'
import { fileEditBasename, firstStringField, isFileEditTool, parseMaybeObject } from './fallback-model'
/**
* The little a summary needs from a tool call, stated structurally so both
* shapes of tool part satisfy it the stored `ChatMessagePart` and the live
* one assistant-ui hands to a renderer.
*/
export interface ToolCallLike {
args?: unknown
result?: unknown
toolCallId?: string
toolName: string
}
export function isToolCallPart<T extends { type: string }>(part: T): part is Extract<T, { type: 'tool-call' }> {
return part.type === 'tool-call'
}
type RunCategory = 'delegate' | 'edit' | 'explore' | 'other' | 'run'
// Clause order is fixed so the same run always reads the same way, whichever
// category happens to be live.
const CATEGORY_ORDER: readonly RunCategory[] = ['edit', 'explore', 'run', 'delegate', 'other']
const CATEGORY_COPY: Record<RunCategory, { noun: [string, string]; past: string; present: string }> = {
delegate: { noun: ['task', 'tasks'], past: 'Delegated', present: 'Delegating' },
edit: { noun: ['file', 'files'], past: 'Edited', present: 'Editing' },
explore: { noun: ['file', 'files'], past: 'Explored', present: 'Exploring' },
other: { noun: ['tool', 'tools'], past: 'Used', present: 'Using' },
run: { noun: ['command', 'commands'], past: 'Ran', present: 'Running' }
}
const EXPLORE_TOOLS = new Set([
'list_files',
'read_file',
'search_files',
'session_search_recall',
'vision_analyze',
'web_extract',
'web_search'
])
function toolCategory(toolName: string): RunCategory {
if (isFileEditTool(toolName)) {
return 'edit'
}
if (toolName === 'terminal' || toolName === 'execute_code') {
return 'run'
}
if (toolName === 'delegate_task') {
return 'delegate'
}
if (EXPLORE_TOOLS.has(toolName) || toolName.startsWith('browser_')) {
return 'explore'
}
return 'other'
}
function isPending(tool: ToolCallLike): boolean {
return tool.result === undefined
}
/**
* How a tool reads while it is happening "Editing", "Exploring". Shared with
* the status line that covers the gap before a tool starts, so the same run is
* described in the same words from the moment the model drafts it.
*/
export function toolPresentVerb(toolName: string): string {
return CATEGORY_COPY[toolCategory(toolName)].present
}
/** The thing a tool acted on, as the header should name it. */
function toolTarget(tool: ToolCallLike): string {
const args = parseMaybeObject(tool.args)
if (toolCategory(tool.toolName) === 'run') {
return summarizeShellCommand(firstStringField(args, ['command', 'code']))
}
const path = firstStringField(args, ['path', 'file', 'filepath'])
return path ? fileEditBasename(path) : firstStringField(args, ['query', 'url'])
}
/**
* One clause per category. A category holding a single thing says what it was
* ("Edited wiring.tsx"); anything else counts ("explored 3 files"). A settled
* command is the exception "ran 5 commands" is the useful reading, and a
* command line only earns its space while it's the thing you're waiting on.
*/
function clause(category: RunCategory, tools: ToolCallLike[], live: boolean): string {
const copy = CATEGORY_COPY[category]
const verb = live ? copy.present : copy.past
const target = tools.length === 1 ? toolTarget(tools[0]) : ''
if (target && (live || category !== 'run')) {
return `${verb} ${target}`
}
return `${verb} ${tools.length} ${copy.noun[tools.length === 1 ? 0 : 1]}`
}
function lowerFirst(text: string): string {
return text.charAt(0).toLowerCase() + text.slice(1)
}
/**
* Collapse a run of tool calls into the single grey line that stands in for it
* "Explored 3 files, ran 5 commands". While the run is live, the category
* holding its most recent call speaks in the present tense so the line reads as
* work in progress rather than work already done.
*
* Whether the run is `live` is the caller's to say, not something readable off
* the calls: a call can be left without a result by a turn that ended or an
* agent that moved on, and a run like that has to read as finished rather than
* narrate work that stopped happening.
*
* A run only ever holds ephemeral activity file edits and other cards are
* split out before this sees them (`splitRunItems`), so there is no aggregate
* diff to report here; each edit carries its own +N/M on its card.
*/
export function summarizeToolRun(tools: readonly ToolCallLike[], live: boolean): string {
// Which clause narrates in the present tense: normally the outstanding call,
// but sequential calls leave gaps where the run is still going and nothing is
// pending. The most recent call covers those, and it's the one the ticker is
// showing anyway.
const narrating = live ? (tools.find(isPending) ?? tools.at(-1)) : undefined
const liveCategory = narrating ? toolCategory(narrating.toolName) : null
const byCategory = new Map<RunCategory, ToolCallLike[]>()
for (const tool of tools) {
const category = toolCategory(tool.toolName)
const group = byCategory.get(category)
if (group) {
group.push(tool)
} else {
byCategory.set(category, [tool])
}
}
const clauses = CATEGORY_ORDER.flatMap(category => {
const group = byCategory.get(category)
return group ? [clause(category, group, category === liveCategory)] : []
})
return clauses.map((text, index) => (index === 0 ? text : lowerFirst(text))).join(', ')
}

View file

@ -0,0 +1,633 @@
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 { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
import { $activeSessionId } from '@/store/session'
import { clearDismissedToolRows } from '@/store/tool-dismiss'
import { $toolDisclosureStates } from '@/store/tool-view'
import { Thread } from '../thread'
// A run of tool calls collapses to a one-line summary once it has settled, but
// a run with anything still pending always renders its rows. That rule is what
// keeps the "approval must never be buried" bug fixed: an inline ApprovalBar
// only ever exists on a pending tool, and a pending tool's run is never behind
// a chevron. These cover both halves — the collapse itself, and the approval
// staying in the visual flow.
const createdAt = new Date('2026-06-03T00: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)
}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
Element.prototype.scrollTo = function scrollTo() {}
Element.prototype.animate = function animate() {
return {
cancel: () => {},
finished: Promise.resolve()
} as unknown as Animation
}
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
fallback: number
) {
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
Object.defineProperty(HTMLElement.prototype, prop, {
configurable: true,
get() {
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
}
})
}
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
// A running assistant message with two tools: a completed read_file plus a
// pending terminal (no result), rendered as a flat two-row list.
function groupedPendingMessage(): ThreadMessage {
return {
id: 'assistant-group-1',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'read-1',
toolName: 'read_file',
args: { path: '/etc/hosts' },
argsText: JSON.stringify({ path: '/etc/hosts' }),
result: { content: '127.0.0.1 localhost' }
},
{
type: 'tool-call',
toolCallId: 'term-1',
toolName: 'terminal',
args: { command: 'rm -rf /tmp/x' },
argsText: JSON.stringify({ command: 'rm -rf /tmp/x' })
}
],
status: { type: 'running' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function pendingOnlyMessage(): ThreadMessage {
return {
id: 'assistant-pending-only',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'term-only',
toolName: 'terminal',
args: { command: 'sleep 10' },
argsText: JSON.stringify({ command: 'sleep 10' })
}
],
status: { type: 'running' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function completedOnlyMessage(): ThreadMessage {
return {
id: 'assistant-completed-only',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'read-only',
toolName: 'read_file',
args: { path: '/etc/hosts' },
argsText: JSON.stringify({ path: '/etc/hosts' }),
result: { content: '127.0.0.1 localhost' }
}
],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function failedOnlyMessage(): ThreadMessage {
return {
id: 'assistant-failed-only',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'term-failed',
toolName: 'terminal',
args: { command: 'exit 1' },
argsText: JSON.stringify({ command: 'exit 1' }),
isError: true,
result: { stderr: 'boom' }
}
],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
// Two settled activity calls in a row, so the run earns a summary line and
// collapses behind it.
function settledRunMessage(): ThreadMessage {
return {
id: 'assistant-settled-run',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'read-2',
toolName: 'read_file',
args: { path: '/repo/src/wiring.tsx' },
argsText: JSON.stringify({ path: '/repo/src/wiring.tsx' }),
result: { content: 'export const Wiring = () => null' }
},
{
type: 'tool-call',
toolCallId: 'term-3',
toolName: 'terminal',
args: { command: 'ls -la' },
argsText: JSON.stringify({ command: 'ls -la' }),
result: { exit_code: 0, stdout: 'wiring.tsx' }
}
],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
// Activity, an edit, then more activity — all adjacent, so assistant-ui hands
// the whole stretch over as one group. The edit is the deliverable and has to
// survive that as its own card.
function editBetweenRunsMessage(): ThreadMessage {
return {
id: 'assistant-edit-between-runs',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'read-5',
toolName: 'read_file',
args: { path: '/repo/src/a.ts' },
argsText: JSON.stringify({ path: '/repo/src/a.ts' }),
result: { content: 'a' }
},
{
type: 'tool-call',
toolCallId: 'search-3',
toolName: 'search_files',
args: { query: 'toolRuns' },
argsText: JSON.stringify({ query: 'toolRuns' }),
result: { hits: [] }
},
{
type: 'tool-call',
toolCallId: 'patch-2',
toolName: 'patch',
args: { path: '/repo/src/wiring.tsx' },
argsText: JSON.stringify({ path: '/repo/src/wiring.tsx' }),
result: { path: '/repo/src/wiring.tsx', inline_diff: '--- a\n+++ b\n+added line\n-removed line' }
},
{
type: 'tool-call',
toolCallId: 'read-6',
toolName: 'read_file',
args: { path: '/repo/src/b.ts' },
argsText: JSON.stringify({ path: '/repo/src/b.ts' }),
result: { content: 'b' }
},
{
type: 'tool-call',
toolCallId: 'term-4',
toolName: 'terminal',
args: { command: 'ls' },
argsText: JSON.stringify({ command: 'ls' }),
result: { exit_code: 0 }
}
],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
// A finished turn that left a call without a result — interrupted, or the
// result landed elsewhere. The run is history and has to behave like it.
function abandonedRunMessage(): ThreadMessage {
return {
id: 'assistant-abandoned-run',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'read-3',
toolName: 'read_file',
args: { path: '/repo/src/status.tsx' },
argsText: JSON.stringify({ path: '/repo/src/status.tsx' }),
result: { content: 'export const Status = () => null' }
},
{
type: 'tool-call',
toolCallId: 'search-1',
toolName: 'search_files',
args: { query: 'toolRuns' },
argsText: JSON.stringify({ query: 'toolRuns' })
}
],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
// The gap between one sequential call finishing and the next arriving: the
// turn is still running, the run is still the tail, but for this instant every
// call has a result.
function betweenSequentialCallsMessage(): ThreadMessage {
return {
id: 'assistant-between-calls',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'term-a',
toolName: 'terminal',
args: { command: 'sleep 2; echo alpha' },
argsText: JSON.stringify({ command: 'sleep 2; echo alpha' }),
result: { exit_code: 0, stdout: 'alpha' }
},
{
type: 'tool-call',
toolCallId: 'term-b',
toolName: 'terminal',
args: { command: 'sleep 2; echo bravo' },
argsText: JSON.stringify({ command: 'sleep 2; echo bravo' }),
result: { exit_code: 0, stdout: 'bravo' }
}
],
status: { type: 'running' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
// Still streaming, but the agent has moved past its first run and left both of
// its calls unresolved. Only the run at the tail is still live.
function movedOnMessage(): ThreadMessage {
return {
id: 'assistant-moved-on',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'read-4',
toolName: 'read_file',
args: { path: '/repo/src/status.tsx' },
argsText: JSON.stringify({ path: '/repo/src/status.tsx' })
},
{
type: 'tool-call',
toolCallId: 'search-2',
toolName: 'search_files',
args: { query: 'toolRuns' },
argsText: JSON.stringify({ query: 'toolRuns' })
},
{ type: 'text', text: 'Let me read the rest of the file.' },
{
type: 'tool-call',
toolCallId: 'term-2',
toolName: 'terminal',
args: { command: 'ls' },
argsText: JSON.stringify({ command: 'ls' })
}
],
status: { type: 'running' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function GroupHarness({ message }: { message: ThreadMessage }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
isRunning: message.status?.type === 'running',
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
beforeEach(() => {
clearAllPrompts()
$activeSessionId.set('sess-1')
$toolDisclosureStates.set({})
clearDismissedToolRows()
})
afterEach(() => {
cleanup()
clearAllPrompts()
$activeSessionId.set(null)
clearDismissedToolRows()
})
describe('settled tool run', () => {
it('collapses to a summary line naming the work', async () => {
const { container } = render(<GroupHarness message={settledRunMessage()} />)
expect(await screen.findByText('Explored wiring.tsx, ran 1 command')).toBeTruthy()
expect(container.querySelectorAll('[data-tool-row]')).toHaveLength(0)
})
it('expands to the underlying rows when the summary is clicked', async () => {
const { container } = render(<GroupHarness message={settledRunMessage()} />)
fireEvent.click(await screen.findByText('Explored wiring.tsx, ran 1 command'))
await waitFor(() => {
expect(container.querySelectorAll('[data-tool-row]').length).toBeGreaterThan(0)
})
})
it('leaves a lone tool call as its own row, with no summary above it', async () => {
const { container } = render(<GroupHarness message={completedOnlyMessage()} />)
await waitFor(() => {
expect(container.querySelectorAll('[data-tool-row]').length).toBe(1)
})
expect(container.querySelector('[data-tool-summary]')).toBeNull()
})
})
// A diff is what the user reviews, so it is never what gets summarized away.
// It stays on screen at the point in the turn where it happened, with the
// activity either side of it collapsing around it.
describe('a file edit among ordinary activity', () => {
it('stays visible between the two runs it interrupted', async () => {
const { container } = render(<GroupHarness message={editBetweenRunsMessage()} />)
await screen.findByText('Explored 2 files')
const shape = [...container.querySelectorAll('[data-tool-summary],[data-tool-row]')].map(node =>
node.hasAttribute('data-tool-summary') ? 'summary' : 'row'
)
expect(shape).toEqual(['summary', 'row', 'summary'])
})
it('keeps the diff itself on screen rather than behind the summary', async () => {
const { container } = render(<GroupHarness message={editBetweenRunsMessage()} />)
await waitFor(() => {
expect(container.querySelector('[data-tool-row][data-file-edit]')).not.toBeNull()
})
})
})
// The transcript rests its scaffolding at a fade, keyed off one attribute. A
// surface that renders without it is brighter than everything around it, which
// is how two adjacent, identical rows came to sit at two opacities.
describe('transcript fade', () => {
it('marks every row and summary as scaffolding', async () => {
const { container } = render(<GroupHarness message={editBetweenRunsMessage()} />)
await screen.findByText('Explored 2 files')
const unmarked = [...container.querySelectorAll('[data-tool-summary],[data-tool-row]')].filter(
node => !node.hasAttribute('data-conversation-scaffold')
)
expect(unmarked).toHaveLength(0)
})
})
describe('live tool run', () => {
it('keeps its rows on screen instead of hiding them behind the summary', async () => {
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
await waitFor(() => {
expect(container.querySelectorAll('[data-tool-row]').length).toBeGreaterThan(0)
})
})
it('cannot be collapsed while a tool is still running', async () => {
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
await waitFor(() => {
expect(container.querySelector('[data-tool-summary]')).not.toBeNull()
})
expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).toBeNull()
})
// Liveness used to also require an unresolved call, which is false for the
// instant between one sequential call finishing and the next arriving — so a
// string of commands settled and re-opened between every one, unmounting the
// ticker and dropping its reel back to the first row instead of scrolling.
it('stays live in the gap between two sequential calls', async () => {
const { container } = render(<GroupHarness message={betweenSequentialCallsMessage()} />)
expect(await screen.findByText('Running 2 commands')).toBeTruthy()
expect(container.querySelector('[data-tool-ticker]')).not.toBeNull()
expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).toBeNull()
})
})
// A run whose calls never resolved used to read as live forever, which stranded
// it in the present tense and — because a live run withholds its toggle — left
// it permanently expanded with no way to collapse it.
describe('tool run left unresolved', () => {
it('settles with the turn rather than narrating work that stopped', async () => {
const { container } = render(<GroupHarness message={abandonedRunMessage()} />)
expect(await screen.findByText('Explored 2 files')).toBeTruthy()
expect(container.querySelectorAll('[data-tool-row]')).toHaveLength(0)
expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).not.toBeNull()
})
it('settles once the agent moves on, even mid-turn', async () => {
const { container } = render(<GroupHarness message={movedOnMessage()} />)
expect(await screen.findByText('Explored 2 files')).toBeTruthy()
expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).not.toBeNull()
})
})
describe('flat tool list approval surfacing', () => {
it('renders no inline approval bar when there is no live approval', async () => {
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
// The pending terminal row mounts immediately, but its inline ApprovalBar
// returns null while $approvalRequest is empty.
await waitFor(() => {
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
})
expect(container.querySelector('[data-slot="tool-approval-inline"]')).toBeNull()
})
it('surfaces the approval inline and never under a hidden ancestor', async () => {
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
await waitFor(() => {
const bar = container.querySelector('[data-slot="tool-approval-inline"]')
expect(bar).not.toBeNull()
// Flat rows live directly in the flow — nothing should ever wrap the bar
// in a `hidden` subtree.
expect(bar?.closest('[hidden]')).toBeNull()
})
})
it('lets completed tool rows be dismissed', async () => {
const { container } = render(<GroupHarness message={completedOnlyMessage()} />)
const dismiss = await screen.findByLabelText('Dismiss')
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
fireEvent.click(dismiss)
await waitFor(() => {
expect(screen.queryByLabelText('Dismiss')).toBeNull()
})
})
it('keeps a dismissed row hidden after a remount (virtualization)', async () => {
// The thread virtualizes, so a row's component unmounts/remounts as it
// scrolls. Dismissal must persist across that — component-local state would
// forget it and the row would pop back. Simulate the remount by unmounting
// and rendering the same message fresh.
const first = render(<GroupHarness message={completedOnlyMessage()} />)
fireEvent.click(await screen.findByLabelText('Dismiss'))
await waitFor(() => {
expect(screen.queryByLabelText('Dismiss')).toBeNull()
})
first.unmount()
render(<GroupHarness message={completedOnlyMessage()} />)
// The row is the only thing this message renders, so staying dismissed
// means nothing comes back — including its dismiss control.
await waitFor(() => {
expect(screen.queryByLabelText('Dismiss')).toBeNull()
})
})
it('lets failed tool rows be dismissed', async () => {
render(<GroupHarness message={failedOnlyMessage()} />)
const dismiss = await screen.findByLabelText('Dismiss')
fireEvent.click(dismiss)
await waitFor(() => {
expect(screen.queryByLabelText('Dismiss')).toBeNull()
})
})
it('does not show dismiss for pending tool rows', async () => {
const { container } = render(<GroupHarness message={pendingOnlyMessage()} />)
await waitFor(() => {
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
})
expect(screen.queryByLabelText('Dismiss')).toBeNull()
})
})

View file

@ -1,7 +1,7 @@
import { act, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { __resetElapsedTimerRegistryForTests, useElapsedSeconds } from './activity-timer'
import { __resetElapsedTimerRegistryForTests, useElapsedSeconds, useMeasuredDuration } from './activity-timer'
function Probe({ active, since, timerKey }: { active: boolean; since?: number; timerKey?: string }) {
const elapsed = useElapsedSeconds(active, timerKey, since)
@ -9,6 +9,12 @@ function Probe({ active, since, timerKey }: { active: boolean; since?: number; t
return <span data-testid="elapsed">{elapsed}</span>
}
function DurationProbe({ active, timerKey }: { active: boolean; timerKey: string }) {
const measured = useMeasuredDuration(active, timerKey)
return <span data-testid="measured">{measured === null ? 'unknown' : measured}</span>
}
describe('useElapsedSeconds', () => {
beforeEach(() => {
vi.useFakeTimers()
@ -67,3 +73,84 @@ describe('useElapsedSeconds', () => {
expect(screen.getByTestId('elapsed').textContent).toBe('0')
})
})
describe('useMeasuredDuration', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
__resetElapsedTimerRegistryForTests()
})
afterEach(() => {
vi.useRealTimers()
__resetElapsedTimerRegistryForTests()
})
it('has nothing to report until it has watched something finish', () => {
render(<DurationProbe active timerKey="reasoning:m1:0" />)
act(() => {
vi.advanceTimersByTime(4_000)
})
expect(screen.getByTestId('measured').textContent).toBe('unknown')
})
it('freezes the duration at the moment the thing finishes', () => {
const { rerender } = render(<DurationProbe active timerKey="reasoning:m1:0" />)
act(() => {
vi.advanceTimersByTime(4_000)
})
rerender(<DurationProbe active={false} timerKey="reasoning:m1:0" />)
expect(screen.getByTestId('measured').textContent).toBe('4')
// Time keeps passing; the block is over and its duration must not creep up
// with it.
act(() => {
vi.advanceTimersByTime(9_000)
})
expect(screen.getByTestId('measured').textContent).toBe('4')
})
// The thread virtualizes, so the component that watched a block finish is
// usually gone by the time anyone scrolls back to read it.
it('remembers the duration for a component that mounts after the fact', () => {
const first = render(<DurationProbe active timerKey="reasoning:m1:0" />)
act(() => {
vi.advanceTimersByTime(6_000)
})
first.rerender(<DurationProbe active={false} timerKey="reasoning:m1:0" />)
first.unmount()
render(<DurationProbe active={false} timerKey="reasoning:m1:0" />)
expect(screen.getByTestId('measured').textContent).toBe('6')
})
it('measures each key separately', () => {
const first = render(<DurationProbe active timerKey="reasoning:m1:0" />)
act(() => {
vi.advanceTimersByTime(3_000)
})
first.rerender(<DurationProbe active={false} timerKey="reasoning:m1:0" />)
first.unmount()
const second = render(<DurationProbe active timerKey="reasoning:m1:7" />)
act(() => {
vi.advanceTimersByTime(2_000)
})
second.rerender(<DurationProbe active={false} timerKey="reasoning:m1:7" />)
expect(screen.getByTestId('measured').textContent).toBe('2')
})
})

View file

@ -5,6 +5,10 @@ import { useEffect, useRef, useState } from 'react'
// anonymous timers (no key) start fresh each mount.
const startedAtByKey = new Map<string, number>()
// Durations of things that have already finished, kept beside the origins that
// measured them. See `useMeasuredDuration`.
const durationByKey = new Map<string, number>()
function startedAt(key?: string): number {
if (!key) {
return Date.now()
@ -71,6 +75,41 @@ export function useElapsedSeconds(active = true, timerKey?: string, since?: numb
return elapsed
}
/**
* How long something took, measured by watching it finish and remembered
* afterwards. `null` until it has been watched at least once.
*
* Some durations exist nowhere but in the watching. A reasoning block is the
* case this was written for: the persisted turn records the text the model
* thought, never how long it spent thinking it, so the only way to know is to
* have been there. Watching alone isn't enough either the thread virtualizes,
* so the component that saw a block finish is usually gone by the time anyone
* scrolls back to read it. Keeping the number in the same registry as the
* timer's origin lets it outlive the component that measured it.
*
* A block that was never watched running history loaded from an earlier app
* session, or reasoning that arrived already complete has no duration and
* says so, rather than reporting a timer that never ran.
*/
export function useMeasuredDuration(active: boolean, timerKey: string): null | number {
const elapsed = useElapsedSeconds(active, timerKey)
const [watching, setWatching] = useState(false)
const [measured, setMeasured] = useState<null | number>(() => durationByKey.get(timerKey) ?? null)
useEffect(() => {
if (active) {
setWatching(true)
} else if (watching) {
setWatching(false)
durationByKey.set(timerKey, elapsed)
setMeasured(elapsed)
}
}, [active, elapsed, timerKey, watching])
return measured
}
export function __resetElapsedTimerRegistryForTests() {
startedAtByKey.clear()
durationByKey.clear()
}

View file

@ -0,0 +1,45 @@
import type { ReactNode } from 'react'
import { DisclosureRow } from '@/components/chat/disclosure-row'
/**
* Transcript scaffolding: the quiet lines around the reply that say what the
* agent did rather than what it said a thinking header, a settled tool run,
* the live activity ticker.
*
* They all render through here so they cannot drift apart. They used to each
* pick their own grey a thinking header painted `--ui-text-secondary`, a tool
* summary `--ui-text-tertiary`, both under the same opacity which read as two
* different kinds of line for what is one kind of thing.
*
* The resting fade is the other half and lives in CSS, on the *block* that
* holds the row rather than on the row: mark it `data-conversation-scaffold`.
* A surface that skips the mark reads a shade brighter than its neighbours.
*/
export const SCAFFOLD_LABEL_CLASS =
'text-[length:var(--conversation-tool-font-size)] leading-(--conversation-line-height) text-(--conversation-scaffold-text)'
/** Durations, counts and diff stats trailing a scaffold label. */
export const SCAFFOLD_META_CLASS = 'shrink-0 text-[0.625rem] tabular-nums text-(--conversation-scaffold-meta)'
/**
* One scaffold line. `children` is the label and whatever trails it in flow
* (meta, diff counts); `trailing` overlays the right edge for a live timer.
*/
export function ScaffoldRow({
children,
onToggle,
open = false,
trailing
}: {
children: ReactNode
onToggle?: () => void
open?: boolean
trailing?: ReactNode
}) {
return (
<DisclosureRow onToggle={onToggle} open={open} trailing={trailing}>
<span className="flex min-w-0 items-center gap-1.5">{children}</span>
</DisclosureRow>
)
}

View file

@ -2273,6 +2273,9 @@ export const ar = defineLocale({
resumeWhenBackgroundDone: count =>
count === 1 ? 'سيُستأنف عند انتهاء المهمة الخلفية' : `سيُستأنف عند انتهاء ${count} مهام خلفية`,
thinking: 'يفكر...',
thought: 'فكّر',
thoughtBriefly: 'فكّر قليلاً',
thoughtFor: duration => `فكّر لمدة ${duration}`,
today: time => `اليوم ${time}`,
yesterday: time => `أمس ${time}`,
copy: 'نسخ',

View file

@ -2670,6 +2670,9 @@ export const en: Translations = {
? 'Will resume when the background task finishes'
: `Will resume when ${count} background tasks finish`,
thinking: 'Thinking',
thought: 'Thought',
thoughtBriefly: 'Thought briefly',
thoughtFor: duration => `Thought for ${duration}`,
today: time => `Today, ${time}`,
yesterday: time => `Yesterday, ${time}`,
copy: 'Copy',

View file

@ -2517,6 +2517,9 @@ export const ja = defineLocale({
? 'バックグラウンドタスクの完了後に再開します'
: `${count} 件のバックグラウンドタスクの完了後に再開します`,
thinking: '考え中',
thought: '思考済み',
thoughtBriefly: '少し思考',
thoughtFor: duration => `${duration} 思考`,
today: time => `今日 ${time}`,
yesterday: time => `昨日 ${time}`,
copy: 'コピー',

View file

@ -2268,6 +2268,9 @@ export interface Translations {
loadingResponse: string
resumeWhenBackgroundDone: (count: number) => string
thinking: string
thought: string
thoughtBriefly: string
thoughtFor: (duration: string) => string
today: (time: string) => string
yesterday: (time: string) => string
copy: string

View file

@ -2438,6 +2438,9 @@ export const zhHant = defineLocale({
resumeWhenBackgroundDone: count =>
count === 1 ? '背景工作完成後將自動繼續' : `${count} 個背景工作完成後將自動繼續`,
thinking: '思考中',
thought: '已思考',
thoughtBriefly: '思考了片刻',
thoughtFor: duration => `思考了 ${duration}`,
today: time => `今天,${time}`,
yesterday: time => `昨天,${time}`,
copy: '複製',

View file

@ -2841,6 +2841,9 @@ export const zh: Translations = {
resumeWhenBackgroundDone: count =>
count === 1 ? '后台任务完成后将自动继续' : `${count} 个后台任务完成后将自动继续`,
thinking: '思考中',
thought: '已思考',
thoughtBriefly: '思考了片刻',
thoughtFor: duration => `思考了 ${duration}`,
today: time => `今天,${time}`,
yesterday: time => `昨天,${time}`,
copy: '复制',

View file

@ -0,0 +1,250 @@
import { describe, expect, it } from 'vitest'
import { isToolCallPart, type ToolCallLike } from '@/components/assistant-ui/tool/run-summary'
import type { SessionMessage } from '@/types/hermes'
import type { ChatMessage, ChatMessagePart } from './chat-messages'
import {
appendAssistantTextPart,
appendReasoningPart,
assistantTextPart,
mergeFinalAssistantText,
toChatMessages,
upsertToolPart
} from './chat-messages'
import { coalesceToolOnlyAssistants, createToolMergeCache } from './chat-runtime'
/**
* A turn described once, replayed two ways: as the gateway event stream the
* live view builds bubbles from, and as the persisted rows `toChatMessages`
* rehydrates on resume. Grouping is only stable if both produce the same runs.
*/
type TurnStep =
| { kind: 'interim'; text: string }
| { kind: 'final'; text: string }
| { kind: 'reasoning'; text: string }
| { kind: 'text'; text: string }
| { kind: 'tool'; id: string; name: string }
// Mirrors use-message-stream: deltas and tool events accumulate on one pending
// bubble; `message.interim` seals it in place and starts a fresh one; and
// `message.complete` merges the final text onto whatever bubble is open.
function replayLive(steps: TurnStep[]): ChatMessage[] {
const messages: ChatMessage[] = []
let streamIndex = 0
let open: ChatMessage | null = null
const openBubble = (): ChatMessage => {
if (open) {
return open
}
streamIndex += 1
open = { id: `assistant-stream-${streamIndex}`, role: 'assistant', parts: [], pending: true }
messages.push(open)
return open
}
const seal = (text: string, interim: boolean) => {
const bubble = open ?? openBubble()
bubble.parts = mergeFinalAssistantText(bubble.parts, text)
bubble.pending = false
bubble.interim = interim
open = null
}
for (const step of steps) {
switch (step.kind) {
case 'interim':
seal(step.text, true)
break
case 'final':
seal(step.text, false)
break
case 'reasoning': {
const bubble = openBubble()
bubble.parts = appendReasoningPart(bubble.parts, step.text)
break
}
case 'text': {
const bubble = openBubble()
bubble.parts = appendAssistantTextPart(bubble.parts, step.text)
break
}
case 'tool': {
const bubble = openBubble()
bubble.parts = upsertToolPart(bubble.parts, { tool_id: step.id, name: step.name }, 'complete')
break
}
}
}
return coalesceToolOnlyAssistants(messages, createToolMergeCache())
}
// The same turn as the gateway persists it: one row per agent iteration. A row
// is a single API response, so its reasoning and content always precede its own
// tool_calls — anything the agent says after a tool ran belongs to the next row.
function replayStored(steps: TurnStep[]): ChatMessage[] {
const rows: SessionMessage[] = []
let timestamp = 0
let row: (SessionMessage & { tool_calls?: unknown[] }) | null = null
const openRow = (afterTools: boolean) => {
if (row && !(afterTools && row.tool_calls)) {
return row
}
timestamp += 1
row = { role: 'assistant', content: '', timestamp }
rows.push(row)
return row
}
for (const step of steps) {
switch (step.kind) {
case 'interim':
case 'final':
case 'text': {
const current = openRow(true)
current.content = `${current.content ?? ''}${step.text}`
if (step.kind !== 'text') {
row = null
}
break
}
case 'reasoning':
openRow(true).reasoning = step.text
break
case 'tool': {
const current = openRow(false)
current.tool_calls = [
...(current.tool_calls ?? []),
{ id: step.id, function: { name: step.name, arguments: '{}' } }
]
timestamp += 1
rows.push({ role: 'tool', tool_call_id: step.id, tool_name: step.name, content: '{}', timestamp })
break
}
}
}
return coalesceToolOnlyAssistants(toChatMessages(rows), createToolMergeCache())
}
/**
* Maximal spans of back-to-back tool calls the same rule assistant-ui applies
* when it hands `ToolGroupSlot` a range, restated here so these tests check our
* two part streams against each other rather than against the renderer.
*/
function toolRuns(parts: ChatMessagePart[]): ToolCallLike[][] {
const runs: ToolCallLike[][] = []
let previousWasTool = false
for (const part of parts) {
if (!isToolCallPart(part)) {
previousWasTool = false
continue
}
if (previousWasTool) {
runs[runs.length - 1].push(part)
} else {
runs.push([part])
}
previousWasTool = true
}
return runs
}
function runsAcross(messages: ChatMessage[]): string[][] {
return messages
.filter(message => message.role === 'assistant')
.flatMap(message => toolRuns(message.parts))
.map(run => run.map(tool => tool.toolCallId ?? ''))
}
const TURNS: Record<string, TurnStep[]> = {
'narration between two tool runs': [
{ kind: 'interim', text: 'Let me check the config.' },
{ kind: 'tool', id: 'a', name: 'read_file' },
{ kind: 'tool', id: 'b', name: 'read_file' },
{ kind: 'interim', text: 'Now let me edit it.' },
{ kind: 'tool', id: 'c', name: 'write_file' },
{ kind: 'final', text: 'Done.' }
],
'reasoning between two tool runs': [
{ kind: 'tool', id: 'a', name: 'terminal' },
{ kind: 'reasoning', text: 'That failed, try the other path.' },
{ kind: 'tool', id: 'b', name: 'terminal' },
{ kind: 'final', text: 'Fixed.' }
],
'unbroken run of tool calls': [
{ kind: 'tool', id: 'a', name: 'read_file' },
{ kind: 'tool', id: 'b', name: 'read_file' },
{ kind: 'tool', id: 'c', name: 'search_files' },
{ kind: 'final', text: 'Here is what I found.' }
],
'reasoning then tools then final': [
{ kind: 'reasoning', text: 'The user wants the lint config.' },
{ kind: 'tool', id: 'a', name: 'search_files' },
{ kind: 'tool', id: 'b', name: 'read_file' },
{ kind: 'final', text: 'It lives in eslint.config.js.' }
],
'tools with no narration at all': [
{ kind: 'tool', id: 'a', name: 'terminal' },
{ kind: 'final', text: 'Clean.' }
]
}
describe('tool run segmentation survives rehydration', () => {
for (const [name, steps] of Object.entries(TURNS)) {
it(name, () => {
expect(runsAcross(replayLive(steps))).toEqual(runsAcross(replayStored(steps)))
})
}
})
describe('run identity', () => {
const tool = (id: string, name: string): ChatMessagePart =>
({ args: {}, toolCallId: id, toolName: name, type: 'tool-call' }) as ChatMessagePart
it('keeps the first tool call at the head as the run grows', () => {
const [run] = toolRuns([tool('a', 'read_file'), tool('b', 'read_file')])
const [grown] = toolRuns([tool('a', 'read_file'), tool('b', 'read_file'), tool('c', 'terminal')])
expect(grown[0].toolCallId).toBe(run[0].toolCallId)
expect(grown).toHaveLength(3)
})
it('breaks a run on any non-tool part', () => {
const runs = toolRuns([tool('a', 'read_file'), assistantTextPart('Now editing.'), tool('b', 'write_file')])
expect(runs.map(run => run.map(t => t.toolCallId))).toEqual([['a'], ['b']])
})
})

View file

@ -0,0 +1,82 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { useEnterAnimation } from './use-enter-animation'
interface PlayedAnimation {
keyframes: Keyframe[]
options: KeyframeAnimationOptions
}
/**
* Mounts one element through the hook and reports the animation it played, if
* any. jsdom has no Web Animations API, so `animate` is the seam.
*/
function mountAnimated(enabled: boolean, animationKey?: string): PlayedAnimation | undefined {
let played: PlayedAnimation | undefined
function Probe() {
const ref = useEnterAnimation(enabled, animationKey)
return <div ref={ref} />
}
// Defined rather than spied on: jsdom ships no Web Animations API at all, so
// there is no `animate` to wrap.
Object.defineProperty(HTMLElement.prototype, 'animate', {
configurable: true,
value: (keyframes: Keyframe[], options: KeyframeAnimationOptions) => {
played = { keyframes, options }
return {} as Animation
},
writable: true
})
render(<Probe />)
return played
}
afterEach(() => {
cleanup()
Reflect.deleteProperty(HTMLElement.prototype, 'animate')
})
describe('useEnterAnimation', () => {
it('plays once on mount', () => {
const played = mountAnimated(true, 'plays-once')
expect(played).toBeDefined()
expect(played?.keyframes[0]).toMatchObject({ opacity: 0 })
})
it('stays out of the way when disabled', () => {
expect(mountAnimated(false, 'disabled')).toBeUndefined()
})
// A key is only banked once the node survives a microtask, so that a mount
// React immediately tears down doesn't burn it.
it('does not replay for a key that already animated', async () => {
expect(mountAnimated(true, 'replay')).toBeDefined()
await Promise.resolve()
expect(mountAnimated(true, 'replay')).toBeUndefined()
})
/**
* The animation fills forwards, so any value in its last keyframe is held in
* the animation origin of the cascade for the life of the element above
* the stylesheet. Naming an end opacity therefore doesn't just finish the
* fade, it permanently overrules whatever opacity CSS wants the element to
* rest at, and transcript scaffolding rests dimmed. Rows that animated in
* during the turn stayed bright while their rehydrated neighbours faded, and
* no hover could lift the bright ones because the sheet had lost the
* argument. Opacity has to be left to CSS at the end.
*/
it('leaves the resting opacity to the stylesheet', () => {
const played = mountAnimated(true, 'resting-opacity')
expect(played?.options.fill).toBe('both')
expect(played?.keyframes.at(-1)).not.toHaveProperty('opacity')
})
})

View file

@ -82,7 +82,17 @@ export function useEnterAnimation(enabled: boolean, animationKey?: string): (el:
el.animate(
[
{ opacity: 0, transform: 'translateY(0.375rem)' },
{ opacity: 1, transform: 'translateY(0)' }
// No `opacity` on the way out, deliberately. A filled animation holds
// its final value in the animation origin of the cascade, which
// outranks the stylesheet for as long as the element lives — naming 1
// here permanently pinned full opacity onto everything the sheet dims.
// Transcript scaffolding is dimmed that way, so a tool row or thinking
// header kept whichever opacity it happened to mount with: full if it
// animated in during the turn, faded if it was rehydrated or remounted
// past its one-shot key. Adjacent identical rows disagreed, and hover
// couldn't lift the pinned ones. Left neutral, opacity rises to
// whatever CSS says it should be and answers hover afterwards.
{ transform: 'translateY(0)' }
],
{ duration: 180, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'both' }
)

View file

@ -0,0 +1,45 @@
import { atom, computed } from 'nanostores'
// The tool whose arguments the model is streaming right now, and when it
// started. Generating a large payload (a 45 KB write_file) takes seconds during
// which the tool has not begun and the transcript has nothing to say.
// Per-session because a transcript may be a tile, and a tile must never narrate
// the primary chat's work.
const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
export interface DraftingTool {
name: string
since: number
}
export const $draftingToolSessions = atom<Record<string, DraftingTool>>({})
/** What `sessionId` is drafting, if anything. */
export function sessionDraftingTool(sessionId: null | string) {
return computed($draftingToolSessions, sessions => sessions[keyFor(sessionId)] ?? null)
}
export function setSessionDraftingTool(sessionId: string | null | undefined, name: string): void {
const key = keyFor(sessionId)
const sessions = $draftingToolSessions.get()
if (!name) {
if (!(key in sessions)) {
return
}
const next = { ...sessions }
delete next[key]
$draftingToolSessions.set(next)
return
}
// Re-announcing the same tool keeps the original timestamp, so the elapsed
// count reads as one continuous wait rather than restarting.
if (sessions[key]?.name === name) {
return
}
$draftingToolSessions.set({ ...sessions, [key]: { name, since: Date.now() } })
}

View file

@ -287,6 +287,13 @@
--ui-text-secondary: color-mix(in srgb, var(--ui-base) 74%, transparent);
--ui-text-tertiary: color-mix(in srgb, var(--ui-base) 54%, transparent);
--ui-text-quaternary: color-mix(in srgb, var(--ui-base) 36%, transparent);
/* Transcript scaffolding thinking headers, settled tool runs, the live
activity ticker. One colour for all of them (see `ScaffoldRow`), pitched
between the secondary and tertiary greys those lines used to pick
individually, and dimmed once more by the fade rule below so the prose
reading column stays primary. */
--conversation-scaffold-text: color-mix(in srgb, var(--ui-base) 64%, transparent);
--conversation-scaffold-meta: color-mix(in srgb, var(--ui-base) 44%, transparent);
--ui-stroke-primary: color-mix(
in srgb,
var(--ui-accent) var(--theme-stroke-primary-accent-mix),
@ -402,10 +409,6 @@
/* Tight gap between tool rows inside a single action group, so a back-to-back
run still reads as one cohesive sequence. */
--tool-row-gap: 0.375rem;
/* Height of the bounded, auto-scrolling window a long adjacent tool-call run
collapses into (see `.tool-group-scroll`). Sized to show a few rows before
the scroll + top fade kick in. */
--tool-group-scroll-max-h: 6.75rem;
/* Paragraph spacing vertical gap between prose paragraphs, both inside a
markdown block and between consecutive prose parts. Single knob; tweak
freely. */
@ -1413,15 +1416,18 @@ text-* variant utilities. */ .btn-arc {
background: transparent !important;
}
/* Fade scaffolding so the prose reading column stays primary. Two targets:
a thinking disclosure fades as one block, and each *individual* tool row
(`[data-tool-row]`) fades on its own. We deliberately do NOT fade the tool
group wrapper (`[data-tool-group]`): opacity on a parent opens a stacking
context, so a child row can never be more opaque than the group that made
it impossible to keep one row lit (an open diff) while its siblings faded.
With the fade per-row, each row hovers/focuses independently. */
[data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure'],
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-tool-row] {
/* Fade scaffolding so the prose reading column stays primary. Each surface
opts in with `data-conversation-scaffold` thinking header, tool row, run
summary, live status line instead of being named by its own selector here.
Spelling them out individually is how the status line came to sit a shade
brighter than the rows either side of it.
Marked per surface and never on a container: opacity opens a stacking
context, so nothing inside a faded parent can be more opaque than it, and
one row (an open diff) could not stay lit while its siblings dimmed. Hence
the tool group wrapper carries no mark and each row inside it carries its
own no two marked elements nest, so the fade never compounds. */
[data-slot='aui_assistant-message-content'] [data-conversation-scaffold] {
opacity: 0.67;
transition: opacity 120ms ease-out;
}
@ -1429,8 +1435,7 @@ text-* variant utilities. */ .btn-arc {
/* Lift on hover or *keyboard* focus only. `:focus-within` also matches the
focus a mouse click leaves on the disclosure toggle, which kept a row lit
after you clicked to collapse it; `:has(:focus-visible)` excludes that. */
[data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure']:is(:hover, :has(:focus-visible)),
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-tool-row]:is(:hover, :has(:focus-visible)) {
[data-slot='aui_assistant-message-content'] [data-conversation-scaffold]:is(:hover, :has(:focus-visible)) {
opacity: 1;
}
@ -1496,24 +1501,33 @@ text-* variant utilities. */ .btn-arc {
/* Conversation block rhythm. assistant-ui renders each range as a direct child
of the message content with no per-part wrapper, so adjacency rules cover
every pairing first block needs no reset, nested tool rows are untouched.
Two tiers: scaffolding (tool / thinking) gets a roomy block gap so it reads
as separate from the reply; consecutive prose collapses to a tight paragraph
rhythm so split-out text parts don't look like a big gap. */
/* Scaffolding adjacent to anything → roomy block gap. */
One gap between top-level blocks, whatever they are. Spelling out which
*pairs* qualified meant the live status line (not tool, thinking or prose)
fell through every branch and carried its own half-size margin instead. */
[data-slot='aui_assistant-message-content']
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'])
+ :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], .aui-md),
[data-slot='aui_assistant-message-content']
> .aui-md
+ :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall'], .aui-md)
+ :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall'], .aui-md) {
margin-top: var(--turn-block-gap);
}
/* Prose ↔ prose → tight paragraph rhythm, matching in-block paragraph spacing. */
/* Except prose prose: one reading column that happens to arrive as several
parts, so it keeps the in-block paragraph rhythm. Same specificity as the
rule above and deliberately after it. */
[data-slot='aui_assistant-message-content'] > .aui-md + .aui-md {
margin-top: var(--paragraph-gap);
}
/* A streaming turn is sealed into several assistant bubbles as it goes
(`message.interim`) and rehydrates into fewer, so the same two blocks are
sometimes siblings inside one bubble and sometimes split across two. The
flex gap between bubbles is the turn gap half the block gap which made
the rhythm visibly tighten and then relax as a turn settled. Top it up so
the column ticks at one interval either way. */
[data-slot='aui_turn-pair'] > [data-slot='aui_assistant-message-root'] + [data-slot='aui_assistant-message-root'] {
margin-top: calc(var(--turn-block-gap) - var(--conversation-turn-gap));
}
/* Message action bars — flat icon hits with default dim; only the hovered/focused control is full-strength. */
[data-slot='aui_msg-actions'] button {
border: 0;
@ -1565,32 +1579,33 @@ text-* variant utilities. */ .btn-arc {
--thread-last-message-clearance: calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 2rem);
}
/* Long adjacent tool-call run collapsed into a fixed, auto-scrolling window.
ToolGroupSlot pins the newest call to the bottom (unless the user scrolls
up), so a back-to-back run stays compact instead of pushing the reply off
screen. A thin scrollbar keeps the affordance discoverable. */
.tool-group-scroll {
scrollbar-width: thin;
scrollbar-color: var(--ui-stroke-tertiary) transparent;
/* A live tool run, shown as one line. `ToolRunTicker` stacks the run's rows
into a reel and offsets it by the newest row's index, so each new action
slides the one before it up and out of a single-line window the run reads
as one line ticking over in place rather than a list growing down the page.
Rows are clipped to a uniform line box so the offset stays exact whatever a
row contains. */
.tool-ticker {
height: var(--conversation-line-height);
overflow: hidden;
}
/* Break out of the fixed window the moment any row inside is expanded: the
user is now reading a diff/output, so let it grow to full height instead of
peering at it through a ~2-row viewport. Collapsing the row drops it back
into the compact, auto-scrolling window. Beats a larger fixed cap since an
open row already bounds its own payload with an inner scroll. */
.tool-group-scroll:has([data-tool-row][data-tool-open]) {
max-height: none;
-webkit-mask-image: none;
mask-image: none;
.tool-ticker__reel {
transform: translateY(calc(var(--tool-ticker-index, 0) * var(--conversation-line-height) * -1));
transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* Top gradient only applied once the user is scrolled down off the top, so
the oldest visible call fades up under the fade while the first row stays
fully legible when scrolled all the way up. */
.tool-group-scroll--faded {
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black 2rem, black 100%);
mask-image: linear-gradient(to bottom, transparent 0, black 2rem, black 100%);
.tool-ticker__row {
display: flex;
height: var(--conversation-line-height);
align-items: center;
overflow: hidden;
}
@media (prefers-reduced-motion: reduce) {
.tool-ticker__reel {
transition: none;
}
}
@keyframes code-card-stream-enter {