refactor(desktop): give live tool runs a one-line ticker

A live run was the settled view under a CSS max-height: the real rows,
capped at ~6.75rem, with an escape hatch that let an open diff lift the cap
entirely. So it was neither a single line nor an honest expanded block, and a
run could balloon mid-turn.

Split the two presentations instead. Live, a run is its summary plus a
one-line reel that slides each finished action up and out, so a turn touching
thirty files reads as one line ticking over in place. Settled, the summary is
the whole of it until opened. Cards — file edits, clarify, image_generate —
leave the run entirely and stay on screen where they happened, since the diff
or the question IS the point of the turn.

Drops useToolWindow, the bounded-window threshold, the scroll container, the
fade mask and the :has([data-tool-open]) escape hatch, all of which existed to
make "the real rows, but shorter" work.

Also unifies transcript scaffolding on one colour: thinking headers painted
--ui-text-secondary and tool summaries --ui-text-tertiary under a shared
opacity, which is two greys for one kind of line. Both now render through
ScaffoldRow at a token pitched between them.
This commit is contained in:
Brooklyn Nicholson 2026-07-27 18:42:46 -05:00
parent 6ba2f3e644
commit a3c46ac09e
15 changed files with 436 additions and 254 deletions

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
@ -441,6 +442,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
flushQueuedDeltas(sessionId)
pruneFinishedSessionSubagents(sessionId)
setSessionCompacting(sessionId, false)
setSessionDraftingTool(sessionId, '')
compactedTurnRef.current.delete(sessionId)
nativeSubagentSessionsRef.current.delete(sessionId)
// A fresh turn on this session optimistically clears its billing wall;
@ -611,6 +613,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
// last item stuck pending/in_progress. Finished lists keep their linger.
clearActiveSessionTodos(sessionId)
setSessionCompacting(sessionId, false)
setSessionDraftingTool(sessionId, '')
flushQueuedDeltas(sessionId)
@ -677,12 +680,28 @@ 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.
if (!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
}
flushQueuedDeltas(sessionId)
setSessionDraftingTool(sessionId, '')
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running', event.type)
if (isActiveEvent) {
@ -691,6 +710,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
} else if (event.type === 'tool.complete') {
if (sessionId) {
flushQueuedDeltas(sessionId)
setSessionDraftingTool(sessionId, '')
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type)
if (isActiveEvent) {
@ -982,6 +1002,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
clearClarifyRequest(undefined, sessionId)
clearActiveSessionTodos(sessionId)
setSessionCompacting(sessionId, false)
setSessionDraftingTool(sessionId, '')
compactedTurnRef.current.delete(sessionId)
}

View file

@ -11,8 +11,8 @@ import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/mar
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool/fallback'
import { formatElapsed, useElapsedSeconds } 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'
@ -89,14 +89,23 @@ const ThinkingDisclosure: FC<{
}
}, [elapsed, pending, watching])
// The timer counts whole seconds, so a block that finished inside one reports
// "0s" — accurate and useless. Say it was quick and leave the number out.
const thoughtLabel =
pending || thoughtFor === null
? t.assistant.thread.thinking
: thoughtFor < 1
? t.assistant.thread.thoughtBriefly
: t.assistant.thread.thoughtFor(formatElapsed(thoughtFor))
// 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
// (rehydrated history, or reasoning that arrived already complete so we never
// saw it run) 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.
@ -144,24 +153,10 @@ const ThinkingDisclosure: FC<{
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'
)}
>
{thoughtLabel}
</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(

View file

@ -3,6 +3,7 @@ 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 { Codicon } from '@/components/ui/codicon'
@ -13,6 +14,7 @@ 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'
const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({
children,
@ -34,8 +36,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="shimmer min-w-0 truncate text-muted-foreground/55">{children}</span>
)
/** These indicators render inside whichever transcript mounted them, so every
@ -45,6 +47,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 +56,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 +115,18 @@ 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}
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 +195,8 @@ 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)
useEffect(() => {
setQuietSince(undefined)
@ -169,24 +206,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
// 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 className="mt-1.5" 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

@ -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, 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'
@ -41,11 +44,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,
@ -701,109 +705,83 @@ 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 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:
//
// - 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.
//
// 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'])
// Tools whose body must never be hidden — not behind the window's max-height +
// fade mask, and not behind a settled run's summary chevron. A run holding any
// of them stays a plain, fully-visible stack no matter how long it is.
//
// This list is deliberately tiny, because the two things it opts out of are
// already safe for anything ToolEntry renders. For the window: a row 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 a code/diff row mounting
// open (see `defaultOpen`) frees itself the moment it appears, and a collapsed
// row is a one-line status with no body in the DOM to clip. For the chevron: a
// run still holding a pending call always renders its rows, so an approval bar
// can't end up behind one.
//
// Only components that bypass ToolEntry need this opt-out: `clarify` and
// `image_generate` render their own markup, never emit `data-tool-row`, and so
// neither the CSS escape hatch nor the pending-row rule can reach them.
//
// File edits are pointedly NOT here. A settled run collapses its diffs behind
// the summary, which carries their +N/M — one click to read, and the run stays
// compact. Adding them back veto'd the window for nearly every coding session
// last time (7f2971039, reverted in 058aa34d8); it would do the same here.
const UNBOUNDABLE_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
return items
}
const pin = (entries: readonly ResizeObserverEntry[]) => {
const height = entries[entries.length - 1]?.borderBoxSize?.[0]?.blockSize ?? -1
const grew = height < 0 || height > lastHeight
lastHeight = height
/**
* The live run, as one line.
*
* 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)
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 (
<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 once it has
@ -823,21 +801,22 @@ function ToolRunHeader({
}) {
return (
<div data-tool-summary="">
<DisclosureRow onToggle={onToggle} open={open}>
<span className="flex min-w-0 items-center gap-1.5">
<FadeText className={cn(TOOL_HEADER_TITLE_CLASS, 'truncate text-(--ui-text-tertiary)')}>
{live ? <span className="shimmer">{summary.text}</span> : summary.text}
</FadeText>
<DiffCount added={summary.added} className="text-[0.625rem]" removed={summary.removed} />
</span>
</DisclosureRow>
<ScaffoldRow onToggle={onToggle} open={open}>
<FadeText className={cn(SCAFFOLD_LABEL_CLASS, 'truncate')}>
{live ? <span className="shimmer">{summary.text}</span> : summary.text}
</FadeText>
<DiffCount added={summary.added} className="text-[0.625rem]" removed={summary.removed} />
</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: RunSummary
}
@ -868,8 +847,10 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState {
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)
}
}
@ -880,7 +861,7 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState {
}
/**
* A run of consecutive tool calls, headed by the line that summarizes it.
* 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
@ -889,69 +870,94 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState {
* 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.
*
* A settled run collapses to its header; a live one keeps its rows on screen
* (and past `TOOL_GROUP_SCROLL_THRESHOLD` inside a fixed-height auto-scrolling
* window, so a long run can't shove the reply off screen). `ToolEmbedContext`
* is false so every row still owns its own chrome (timer / copy / approval).
* 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 messageRunning = useAuiState(selectMessageRunning)
const { key, live, summary } = useToolRun(startIndex, endIndex)
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 disclosureId = `tool-run:${key}`
const persistedOpen = useStore($toolDisclosureOpen(disclosureId))
// A lone call is already its own one-line summary, so it never earns a
// header. Neither does a run holding an `UNBOUNDABLE_TOOLS` surface — a
// `clarify` asking the user a question must not end up behind a chevron.
const grouped = endIndex > startIndex && !hasUnboundable
const open = !grouped || live || (persistedOpen ?? false)
const enterRef = useEnterAnimation(messageRunning, `tool-run:${key}`)
const bounded = open && 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="grid min-w-0 max-w-full gap-(--tool-row-gap) overflow-hidden"
data-slot="tool-block"
data-tool-group=""
ref={enterRef}
>
{grouped && (
<ToolRunHeader
live={live}
onToggle={live ? undefined : () => setToolDisclosureOpen(disclosureId, !open)}
open={open}
summary={summary}
/>
)}
{open && (
<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

@ -79,6 +79,15 @@ 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)

View file

@ -0,0 +1,41 @@
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.
*/
export const SCAFFOLD_LABEL_CLASS =
'text-[length:var(--conversation-tool-font-size)] font-medium 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,7 @@ export const ar = defineLocale({
resumeWhenBackgroundDone: count =>
count === 1 ? 'سيُستأنف عند انتهاء المهمة الخلفية' : `سيُستأنف عند انتهاء ${count} مهام خلفية`,
thinking: 'يفكر...',
thought: 'فكّر',
thoughtBriefly: 'فكّر قليلاً',
thoughtFor: duration => `فكّر لمدة ${duration}`,
today: time => `اليوم ${time}`,

View file

@ -2668,6 +2668,7 @@ 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}`,

View file

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

View file

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

View file

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

View file

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

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. */
@ -1567,32 +1570,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 {