diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts
index 00348861930..04aa94522a0 100644
--- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts
+++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts
@@ -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)
}
diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx
index 93a4f24a6eb..1502aa82a91 100644
--- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx
@@ -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}
>
- setUserOpen(!open)} open={open}>
-
-
- {thoughtLabel}
-
- {pending && (
-
- )}
-
-
+ setUserOpen(!open)} open={open}>
+ {thoughtLabel}
+ {pending && }
+
{open && (
> = ({
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 = () => (
-
{COMPACTION_LABEL}
+const HintText: FC<{ children: ReactNode }> = ({ children }) => (
+
{children}
)
/** 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 (
- {compacting && }
+ {hint && {hint}}
)
@@ -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
(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 (
-
+
- {compacting && }
+ {hint && {hint}}
)
diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.test.ts b/apps/desktop/src/components/assistant-ui/tool/fallback.test.ts
index 75bacb338a5..abe38c26f7d 100644
--- a/apps/desktop/src/components/assistant-ui/tool/fallback.test.ts
+++ b/apps/desktop/src/components/assistant-ui/tool/fallback.test.ts
@@ -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([])
})
})
diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx
index 799a87df3ee..70d6d050331 100644
--- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx
+++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx
@@ -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(null)
- const contentRef = useRef(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 = 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 (
+
+
+ {rows.map((row, index) => (
+
+ {row}
+
+ ))}
+
+
+ )
}
// The one grey line that stands in for a run of tool calls once it has
@@ -823,21 +801,22 @@ function ToolRunHeader({
}) {
return (
-
-
-
- {live ? {summary.text} : summary.text}
-
-
-
-
+
+
+ {live ? {summary.text} : summary.text}
+
+
+
)
}
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> = ({
+ 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 (
+
+
setToolDisclosureOpen(disclosureId, !expanded)}
+ open={expanded}
+ summary={summary}
+ />
+ {live && !blocked && {children}}
+ {expanded && {children}
}
+
+ )
+}
+
+/**
+ * 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> = ({
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 (
-
- {grouped && (
-
setToolDisclosureOpen(disclosureId, !open)}
- open={open}
- summary={summary}
- />
- )}
- {open && (
-
- )}
-
+ {items.map(item =>
+ item.kind === 'card' ? (
+ {rows[item.index]}
+ ) : (
+
+ {rows.slice(item.start, item.end + 1)}
+
+ )
+ )}
)
}
diff --git a/apps/desktop/src/components/assistant-ui/tool/run-summary.ts b/apps/desktop/src/components/assistant-ui/tool/run-summary.ts
index 96458435ed4..a23970be4d9 100644
--- a/apps/desktop/src/components/assistant-ui/tool/run-summary.ts
+++ b/apps/desktop/src/components/assistant-ui/tool/run-summary.ts
@@ -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)
diff --git a/apps/desktop/src/components/chat/scaffold-row.tsx b/apps/desktop/src/components/chat/scaffold-row.tsx
new file mode 100644
index 00000000000..d4d27866371
--- /dev/null
+++ b/apps/desktop/src/components/chat/scaffold-row.tsx
@@ -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 (
+
+ {children}
+
+ )
+}
diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts
index 6557fcc87be..2b1e8bd42b5 100644
--- a/apps/desktop/src/i18n/ar.ts
+++ b/apps/desktop/src/i18n/ar.ts
@@ -2273,6 +2273,7 @@ export const ar = defineLocale({
resumeWhenBackgroundDone: count =>
count === 1 ? 'سيُستأنف عند انتهاء المهمة الخلفية' : `سيُستأنف عند انتهاء ${count} مهام خلفية`,
thinking: 'يفكر...',
+ thought: 'فكّر',
thoughtBriefly: 'فكّر قليلاً',
thoughtFor: duration => `فكّر لمدة ${duration}`,
today: time => `اليوم ${time}`,
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 4f5b8fe1048..472ab8cd79d 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -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}`,
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index ec428cc7638..6971ab4b5cc 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -2517,6 +2517,7 @@ export const ja = defineLocale({
? 'バックグラウンドタスクの完了後に再開します'
: `${count} 件のバックグラウンドタスクの完了後に再開します`,
thinking: '考え中',
+ thought: '思考済み',
thoughtBriefly: '少し思考',
thoughtFor: duration => `${duration} 思考`,
today: time => `今日 ${time}`,
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index ae676578905..d385fb3c874 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -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
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index cfcda590134..e4c96d779ff 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -2438,6 +2438,7 @@ export const zhHant = defineLocale({
resumeWhenBackgroundDone: count =>
count === 1 ? '背景工作完成後將自動繼續' : `${count} 個背景工作完成後將自動繼續`,
thinking: '思考中',
+ thought: '已思考',
thoughtBriefly: '思考了片刻',
thoughtFor: duration => `思考了 ${duration}`,
today: time => `今天,${time}`,
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 586b8cedafa..33edaef1198 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -2839,6 +2839,7 @@ export const zh: Translations = {
resumeWhenBackgroundDone: count =>
count === 1 ? '后台任务完成后将自动继续' : `${count} 个后台任务完成后将自动继续`,
thinking: '思考中',
+ thought: '已思考',
thoughtBriefly: '思考了片刻',
thoughtFor: duration => `思考了 ${duration}`,
today: time => `今天,${time}`,
diff --git a/apps/desktop/src/store/tool-drafting.ts b/apps/desktop/src/store/tool-drafting.ts
new file mode 100644
index 00000000000..eae645142be
--- /dev/null
+++ b/apps/desktop/src/store/tool-drafting.ts
@@ -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>({})
+
+/** 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() } })
+}
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index c2a86f4960a..7135a6146b2 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -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 {