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 ( ) @@ -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 ( - + ) 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 && ( -
-
- {children} -
-
- )} -
+ {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 {