diff --git a/apps/desktop/src/app/chat/session-tile-actions.ts b/apps/desktop/src/app/chat/session-tile-actions.ts index 912878e53b03..469691384598 100644 --- a/apps/desktop/src/app/chat/session-tile-actions.ts +++ b/apps/desktop/src/app/chat/session-tile-actions.ts @@ -29,6 +29,7 @@ import { $sessionStates, sessionTileDelegate } from '@/store/session-states' import { broadcastSessionsChanged } from '@/store/session-sync' import { clearSessionSubagents } from '@/store/subagents' import { clearSessionTodos } from '@/store/todos' +import { setSessionDraftingTool } from '@/store/tool-drafting' import type { SessionInfo } from '@/types/hermes' import { uploadComposerAttachment } from '../session/hooks/use-prompt-actions' @@ -253,6 +254,7 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses clearSessionTodos(sessionId) clearSessionSubagents(sessionId) resetSessionBackground(sessionId) + setSessionDraftingTool(sessionId, '') clearAllPrompts(sessionId) clearClarifyRequest(undefined, sessionId) 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 00348861930b..500540b6ccfb 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 @@ -105,6 +106,28 @@ function surfaceBillingBlock(sessionId: string, raw: unknown): void { }) } +/** + * Events that retire a "drafting a tool call" claim. + * + * `tool.generating` opens the claim and nothing closes it — a draft can be + * abandoned without ever reaching `tool.start`, so enumerating the ways one + * *ends* left the label on screen for the rest of the turn. Inverted: the + * claim only covers what the model is emitting right now, and any other output + * from the session proves it moved on. Same rule the TUI applies to its + * transient trail lines (`turnController.pruneTransient`). + */ +const DRAFT_SUPERSEDING_EVENT_TYPES = new Set([ + 'error', + 'message.complete', + 'message.delta', + 'message.start', + 'reasoning.delta', + 'thinking.delta', + 'tool.complete', + 'tool.progress', + 'tool.start' +]) + const COMPACTION_RESUME_EVENT_TYPES = new Set([ 'message.delta', 'message.interim', @@ -243,6 +266,10 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { setSessionCompacting(sessionId, false) } + if (sessionId && DRAFT_SUPERSEDING_EVENT_TYPES.has(event.type)) { + setSessionDraftingTool(sessionId, '') + } + if (event.type === 'gateway.ready') { // Seed the active skin into the desktop theme registry without applying, // so a fresh connect never overrides the user's persisted desktop theme. @@ -677,7 +704,26 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { if (storedId && nextTitle) { setSessions(prev => prev.map(s => (sessionMatchesStoredId(s, storedId) ? { ...s, title: nextTitle } : s))) } - } else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') { + } else if (event.type === 'tool.generating') { + // Announced while the model is still emitting the call's JSON, so it + // carries a name and nothing else — no id, no args. Materializing a row + // from it strands an argless placeholder whenever the bubble is sealed + // before the real `tool.start` arrives, because the two can no longer be + // reconciled across the boundary. It's a status, so say it as one. + // A stopped turn can still emit a frame or two before the backend + // notices, and naming a tool we will never run leaves the label up + // until something else retires it. `mutateStream` drops late tool rows + // on the same condition; the status line has to agree with it. + if (!sessionId || sessionInterrupted(sessionId)) { + return + } + + setSessionDraftingTool(sessionId, typeof payload?.name === 'string' ? payload.name : '') + + if (isActiveEvent) { + setPetActivity({ reasoning: false, toolRunning: true }) + } + } else if (event.type === 'tool.start' || event.type === 'tool.progress') { if (!sessionId) { return } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/tool-drafting-event.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/tool-drafting-event.test.tsx new file mode 100644 index 000000000000..57d5c5b1ced0 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/tool-drafting-event.test.tsx @@ -0,0 +1,124 @@ +import { QueryClient } from '@tanstack/react-query' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { useEffect, useRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ClientSessionState } from '@/app/types' +import { createClientSessionState } from '@/lib/chat-runtime' +import { $draftingToolSessions } from '@/store/tool-drafting' +import type { RpcEvent } from '@/types/hermes' + +import { useMessageStream } from './index' + +const SID = 'session-1' +const OTHER_SID = 'session-2' + +// Module-scoped so a test can seed session state (e.g. interrupted) before the +// handler reads it — `sessionInterrupted` resolves against this map. +const sessionStates = new Map() +let handleEvent: ((event: RpcEvent) => void) | null = null + +function Harness() { + const activeSessionIdRef = useRef(SID) + const sessionStateByRuntimeIdRef = useRef(sessionStates) + const queryClientRef = useRef(new QueryClient()) + + const stream = useMessageStream({ + activeSessionIdRef, + hydrateFromStoredSession: vi.fn(async () => undefined), + queryClient: queryClientRef.current, + refreshHermesConfig: vi.fn(async () => undefined), + refreshSessions: vi.fn(async () => undefined), + sessionStateByRuntimeIdRef, + updateSessionState: (sessionId, updater) => { + const next = updater(sessionStates.get(sessionId) ?? createClientSessionState()) + sessionStates.set(sessionId, next) + + return next + } + }) + + useEffect(() => { + handleEvent = stream.handleGatewayEvent + }, [stream.handleGatewayEvent]) + + return null +} + +async function mountStream() { + render() + await waitFor(() => expect(handleEvent).not.toBeNull()) +} + +function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}, sessionId = SID) { + act(() => handleEvent!({ payload, session_id: sessionId, type })) +} + +function draftedTool(sessionId = SID) { + return $draftingToolSessions.get()[sessionId]?.name +} + +describe('drafting-tool label lifecycle', () => { + beforeEach(() => { + handleEvent = null + sessionStates.clear() + $draftingToolSessions.set({}) + }) + + afterEach(() => { + cleanup() + sessionStates.clear() + $draftingToolSessions.set({}) + vi.restoreAllMocks() + }) + + it('names the tool the model is drafting', async () => { + await mountStream() + + emit('tool.generating', { name: 'write_file' }) + + expect(draftedTool()).toBe('write_file') + }) + + // The label used to be retired only by the events that mean "this tool ran". + // A tool can be abandoned without ever reaching `tool.start` — a mid-stream + // retry drops a partial call, a guardrail-blocked tool skips the lifecycle + // callbacks — and the name then sat on screen for the rest of the turn. + it.each([ + ['message.delta', { text: 'never mind' }], + ['reasoning.delta', { text: 'reconsidering' }], + ['thinking.delta', { text: 'reconsidering' }], + ['tool.start', { name: 'terminal', tool_id: 'tool-1' }], + ['tool.complete', { name: 'terminal', tool_id: 'tool-1' }], + ['message.complete', { text: 'done' }], + ['error', { message: 'boom' }] + ] as const)('retires the label when %s proves the model moved on', async (type, payload) => { + await mountStream() + emit('tool.generating', { name: 'write_file' }) + + emit(type, payload) + + expect(draftedTool()).toBeUndefined() + }) + + it('leaves another session’s label alone', async () => { + await mountStream() + emit('tool.generating', { name: 'patch' }, OTHER_SID) + emit('tool.generating', { name: 'write_file' }) + + emit('message.delta', { text: 'moving on' }) + + expect(draftedTool()).toBeUndefined() + expect(draftedTool(OTHER_SID)).toBe('patch') + }) + + // A stopped turn can still emit a frame or two before the backend notices. + it('ignores a tool announced after the user hit stop', async () => { + sessionStates.set(SID, { ...createClientSessionState(), interrupted: true }) + await mountStream() + + emit('tool.generating', { name: 'write_file' }) + + expect(draftedTool()).toBeUndefined() + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index bee6192894cd..681ef8d4199b 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -33,6 +33,7 @@ import { } from '@/store/session' import { clearSessionSubagents } from '@/store/subagents' import { clearSessionTodos } from '@/store/todos' +import { setSessionDraftingTool } from '@/store/tool-drafting' import type { ClientSessionState, @@ -592,6 +593,7 @@ export function usePromptActions({ clearSessionTodos(sessionId) clearSessionSubagents(sessionId) resetSessionBackground(sessionId) + setSessionDraftingTool(sessionId, '') // Stop ends the turn, so the gateway is no longer blocked on any prompt it // raised. Drop this session's pending clarify / approval / sudo / secret so // a dead panel (and the sidebar "needs input" dot) can't linger and accept 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 05c9809eacb6..63f2c9fb2a17 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx @@ -9,10 +9,10 @@ import { type ComponentProps, type FC, type ReactNode, useEffect, useRef, useSta import { ClarifyTool } from '@/components/assistant-ui/clarify-tool' import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text' import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool/fallback' -import { useElapsedSeconds } from '@/components/chat/activity-timer' +import { formatElapsed, useElapsedSeconds, useMeasuredDuration } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' -import { DisclosureRow } from '@/components/chat/disclosure-row' import { GeneratedImage } from '@/components/chat/generated-image-result' +import { SCAFFOLD_LABEL_CLASS, SCAFFOLD_META_CLASS, ScaffoldRow } from '@/components/chat/scaffold-row' import { useI18n } from '@/i18n' import { generatedImageFromResult } from '@/lib/generated-images' import { useEnterAnimation } from '@/lib/use-enter-animation' @@ -57,7 +57,9 @@ const ThinkingDisclosure: FC<{ children: ReactNode messageRunning?: boolean pending?: boolean - timerKey?: string + // Required: the block's duration is remembered against this key, so a + // component that mounts after the block finished can still report it. + timerKey: string }> = ({ children, messageRunning = false, pending = false, timerKey }) => { const { t } = useI18n() // `null` = no explicit user toggle yet, defer to the streaming default. @@ -66,6 +68,7 @@ const ThinkingDisclosure: FC<{ // explicit toggle wins from then on. const [userOpen, setUserOpen] = useState(null) const elapsed = useElapsedSeconds(pending, timerKey) + const thoughtFor = useMeasuredDuration(pending, timerKey) const scrollRef = useRef(null) const contentRef = useRef(null) const enterRef = useEnterAnimation(messageRunning, timerKey) @@ -73,6 +76,23 @@ const ThinkingDisclosure: FC<{ const open = userOpen ?? pending const isPreview = pending && userOpen === null + // Three ways a finished block can report itself. With a measured duration it + // says so, unless the timer's whole seconds round it to "0s" — accurate and + // useless — in which case it just says it was quick. With no duration at all + // it still has to read as finished; a turn that ended must not go on saying + // "Thinking". + let thoughtLabel = t.assistant.thread.thinking + + if (!pending) { + if (thoughtFor === null) { + thoughtLabel = t.assistant.thread.thought + } else if (thoughtFor < 1) { + thoughtLabel = t.assistant.thread.thoughtBriefly + } else { + thoughtLabel = t.assistant.thread.thoughtFor(formatElapsed(thoughtFor)) + } + } + // While the preview is live, pin the scroll container to the bottom on // every content growth so the latest tokens are always visible. useEffect(() => { @@ -116,27 +136,14 @@ const ThinkingDisclosure: FC<{ return (
- setUserOpen(!open)} open={open}> - - - {t.assistant.thread.thinking} - - {pending && ( - - )} - - + setUserOpen(!open)} open={open}> + {thoughtLabel} + {pending && } + {open && (
+ // Keyed per block, not per message: the timer registry hands every caller + // of a key the same origin, so a turn that thinks three separate times used + // to measure the second and third blocks from the first one's start and + // report the running total as each block's duration. + {children} ) diff --git a/apps/desktop/src/components/assistant-ui/thread/status.test.tsx b/apps/desktop/src/components/assistant-ui/thread/status.test.tsx index f6edaee0d7e3..e51f39313a79 100644 --- a/apps/desktop/src/components/assistant-ui/thread/status.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/status.test.tsx @@ -54,3 +54,18 @@ describe('ResponseLoadingIndicator timer', () => { expect(screen.getAllByText((_, node) => node?.textContent === '8s').length).toBeGreaterThan(0) }) }) + +// The status line sits between tool rows and thinking headers, which the +// transcript rests at a fade. Without the mark it reads a shade brighter than +// both — the one line in the column claiming emphasis it hasn't earned. +describe('status line', () => { + afterEach(cleanup) + + it('is marked as transcript scaffolding', () => { + $activeSessionId.set('session-a') + $turnStartedAt.set(Date.now()) + const { container } = renderIndicator() + + expect(container.querySelector('[role="status"]')?.hasAttribute('data-conversation-scaffold')).toBe(true) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/status.tsx b/apps/desktop/src/components/assistant-ui/thread/status.tsx index 1fb80b549071..83cd107a292c 100644 --- a/apps/desktop/src/components/assistant-ui/thread/status.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/status.tsx @@ -3,8 +3,10 @@ import { useStore } from '@nanostores/react' import { type FC, type ReactNode, useEffect, useMemo, useState } from 'react' import { useSessionView } from '@/app/chat/session-view' +import { toolPresentVerb } from '@/components/assistant-ui/tool/run-summary' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' +import { SCAFFOLD_LABEL_CLASS } from '@/components/chat/scaffold-row' import { Codicon } from '@/components/ui/codicon' import { Loader } from '@/components/ui/loader' import { useI18n } from '@/i18n' @@ -13,7 +15,11 @@ import { $backgroundResume } from '@/store/background-delegation' import { sessionCompacting } from '@/store/compaction' import { sessionAwaitingInput } from '@/store/prompts' import { $turnStartedAt } from '@/store/session' +import { type DraftingTool, sessionDraftingTool } from '@/store/tool-drafting' +// A status line is scaffolding like any other — "Editing" while the model +// drafts a call is the same kind of line as "Explored 3 files" once it has run, +// and reads as one continuous column only if it shares their type and colour. const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({ children, label, @@ -23,7 +29,12 @@ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentProp
@@ -34,8 +45,8 @@ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentProp // Fixed label while auto-compaction runs — decoupled from backend status text. const COMPACTION_LABEL = 'Summarizing thread' -const CompactionHint: FC = () => ( - {COMPACTION_LABEL} +const HintText: FC<{ children: ReactNode }> = ({ children }) => ( + {children} ) /** These indicators render inside whichever transcript mounted them, so every @@ -45,6 +56,7 @@ function useThreadSessionStatus() { const sessionId = useStore(useSessionView().$runtimeId) const turnStartedAt = useStore($turnStartedAt) const compacting = useStore(useMemo(() => sessionCompacting(sessionId), [sessionId])) + const drafting = useStore(useMemo(() => sessionDraftingTool(sessionId), [sessionId])) // A pending clarify / approval / sudo / secret means the turn is paused on the // user, not working — so don't resurrect the "thinking" timer while they // decide (matches the pet's awaitingInput pose taking priority over busy). @@ -53,10 +65,42 @@ function useThreadSessionStatus() { return { awaitingInput, compacting, + drafting, turnTimerKey: sessionId && turnStartedAt ? `turn:${sessionId}:${turnStartedAt}` : undefined } } +// Long enough that a tool whose arguments arrive in a few frames never gets to +// strobe a label, short enough that a real wait is named almost immediately. +const DRAFTING_REVEAL_MS = 200 + +/** + * What to call the wait, if it deserves a name. Compaction outranks a draft — + * it's rarer, slower, and explains a transcript that looks like it reset. + */ +function useStatusHint(compacting: boolean, drafting: DraftingTool | null): string { + const [revealed, setRevealed] = useState(false) + const name = drafting?.name ?? '' + + useEffect(() => { + setRevealed(false) + + if (!name) { + return + } + + const id = window.setTimeout(() => setRevealed(true), DRAFTING_REVEAL_MS) + + return () => window.clearTimeout(id) + }, [name]) + + if (compacting) { + return COMPACTION_LABEL + } + + return revealed && name ? toolPresentVerb(name) : '' +} + export const CenteredThreadSpinner: FC = () => { const { t } = useI18n() @@ -80,17 +124,14 @@ export const CenteredThreadSpinner: FC = () => { export const ResponseLoadingIndicator: FC = () => { const { t } = useI18n() - const { compacting, turnTimerKey } = useThreadSessionStatus() + const { compacting, drafting, turnTimerKey } = useThreadSessionStatus() const elapsed = useElapsedSeconds(true, turnTimerKey) + const hint = useStatusHint(compacting, drafting) return ( - + ) @@ -159,7 +200,13 @@ export const StreamStallIndicator: FC = () => { // what lets the timer read "quiet for 12s" rather than the age of this // component, which is the whole turn so far. const [quietSince, setQuietSince] = useState(undefined) - const { awaitingInput, compacting, turnTimerKey } = useThreadSessionStatus() + const { awaitingInput, compacting, drafting, turnTimerKey } = useThreadSessionStatus() + const hint = useStatusHint(compacting, drafting) + + // A tool run at the tail already narrates the wait — its summary counts the + // calls, its ticker names the current one, and it carries its own timer. A + // second spinner under that adds a line and says nothing new. + const toolNarrating = useAuiState(s => s.message.content.at(-1)?.type === 'tool-call') useEffect(() => { setQuietSince(undefined) @@ -169,24 +216,28 @@ export const StreamStallIndicator: FC = () => { return () => window.clearTimeout(id) }, [activity]) - const active = (quietSince !== undefined || compacting) && !awaitingInput + // A named wait doesn't have to earn the stall threshold first — we already + // know what the turn is doing, so say it as soon as the label is ready rather + // than leaving the transcript silent for STREAM_STALL_S. + const active = (quietSince !== undefined || Boolean(hint)) && !awaitingInput && !toolNarrating // Compaction owns the whole turn, so it keeps counting from the turn's start; - // a plain stall counts from the last thing the stream produced. - const elapsed = useElapsedSeconds(active, compacting ? turnTimerKey : undefined, compacting ? undefined : quietSince) + // anything else counts from the moment the stream went quiet — the stall's own + // mark, or the draft's, whichever named the wait first. + const elapsed = useElapsedSeconds( + active, + compacting ? turnTimerKey : undefined, + compacting ? undefined : (quietSince ?? drafting?.since) + ) if (!active) { return null } return ( - + ) diff --git a/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx b/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx index c9d5dc53baea..0cedda1e7b1f 100644 --- a/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx @@ -606,7 +606,8 @@ describe('assistant-ui streaming renderer', () => { const { container } = render() const ui = within(container) - fireEvent.click(ui.getByRole('button', { name: /thinking/i })) + // Settled, so the header is past tense — a running block says "Thinking". + fireEvent.click(ui.getByRole('button', { name: /thought/i })) expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toBe( 'The user is asking what this file is.' diff --git a/apps/desktop/src/components/assistant-ui/tool/approval-group.test.tsx b/apps/desktop/src/components/assistant-ui/tool/approval-group.test.tsx deleted file mode 100644 index f6201153c98b..000000000000 --- a/apps/desktop/src/components/assistant-ui/tool/approval-group.test.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { clearAllPrompts, setApprovalRequest } from '@/store/prompts' -import { $activeSessionId } from '@/store/session' -import { clearDismissedToolRows } from '@/store/tool-dismiss' -import { $toolDisclosureStates } from '@/store/tool-view' - -import { Thread } from '../thread' - -// Regression coverage for the "approval must never be buried" bug. Tools now -// render as a flat list (no collapsible "N steps" group), so a pending tool's -// inline ApprovalBar is always in the visual flow — never inside a `hidden` -// body. These assert the bar shows only when an approval is live and is never -// trapped under a `hidden` ancestor. - -const createdAt = new Date('2026-06-03T00:00:00.000Z') - -const resizeObservers = new Set() - -class TestResizeObserver { - private target: Element | null = null - - constructor(private readonly callback: ResizeObserverCallback) { - resizeObservers.add(this) - } - - observe(target: Element) { - this.target = target - } - - unobserve() {} - - disconnect() { - resizeObservers.delete(this) - } -} - -vi.stubGlobal('ResizeObserver', TestResizeObserver) -vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => - window.setTimeout(() => callback(performance.now()), 0) -) -vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) - -Element.prototype.scrollTo = function scrollTo() {} - -Element.prototype.animate = function animate() { - return { - cancel: () => {}, - finished: Promise.resolve() - } as unknown as Animation -} - -function stubOffsetDimension( - prop: 'offsetHeight' | 'offsetWidth', - clientProp: 'clientHeight' | 'clientWidth', - fallback: number -) { - const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop) - - Object.defineProperty(HTMLElement.prototype, prop, { - configurable: true, - get() { - return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback - } - }) -} - -stubOffsetDimension('offsetWidth', 'clientWidth', 800) -stubOffsetDimension('offsetHeight', 'clientHeight', 600) - -// A running assistant message with two tools: a completed read_file plus a -// pending terminal (no result), rendered as a flat two-row list. -function groupedPendingMessage(): ThreadMessage { - return { - id: 'assistant-group-1', - role: 'assistant', - content: [ - { - type: 'tool-call', - toolCallId: 'read-1', - toolName: 'read_file', - args: { path: '/etc/hosts' }, - argsText: JSON.stringify({ path: '/etc/hosts' }), - result: { content: '127.0.0.1 localhost' } - }, - { - type: 'tool-call', - toolCallId: 'term-1', - toolName: 'terminal', - args: { command: 'rm -rf /tmp/x' }, - argsText: JSON.stringify({ command: 'rm -rf /tmp/x' }) - } - ], - status: { type: 'running' }, - createdAt, - metadata: { - unstable_state: null, - unstable_annotations: [], - unstable_data: [], - steps: [], - custom: {} - } - } as ThreadMessage -} - -function pendingOnlyMessage(): ThreadMessage { - return { - id: 'assistant-pending-only', - role: 'assistant', - content: [ - { - type: 'tool-call', - toolCallId: 'term-only', - toolName: 'terminal', - args: { command: 'sleep 10' }, - argsText: JSON.stringify({ command: 'sleep 10' }) - } - ], - status: { type: 'running' }, - createdAt, - metadata: { - unstable_state: null, - unstable_annotations: [], - unstable_data: [], - steps: [], - custom: {} - } - } as ThreadMessage -} - -function completedOnlyMessage(): ThreadMessage { - return { - id: 'assistant-completed-only', - role: 'assistant', - content: [ - { - type: 'tool-call', - toolCallId: 'read-only', - toolName: 'read_file', - args: { path: '/etc/hosts' }, - argsText: JSON.stringify({ path: '/etc/hosts' }), - result: { content: '127.0.0.1 localhost' } - } - ], - status: { type: 'complete', reason: 'stop' }, - createdAt, - metadata: { - unstable_state: null, - unstable_annotations: [], - unstable_data: [], - steps: [], - custom: {} - } - } as ThreadMessage -} - -function failedOnlyMessage(): ThreadMessage { - return { - id: 'assistant-failed-only', - role: 'assistant', - content: [ - { - type: 'tool-call', - toolCallId: 'term-failed', - toolName: 'terminal', - args: { command: 'exit 1' }, - argsText: JSON.stringify({ command: 'exit 1' }), - isError: true, - result: { stderr: 'boom' } - } - ], - status: { type: 'complete', reason: 'stop' }, - createdAt, - metadata: { - unstable_state: null, - unstable_annotations: [], - unstable_data: [], - steps: [], - custom: {} - } - } as ThreadMessage -} - -function GroupHarness({ message }: { message: ThreadMessage }) { - const runtime = useExternalStoreRuntime({ - messages: [message], - isRunning: message.status?.type === 'running', - onNew: async () => {} - }) - - return ( - - - - ) -} - -beforeEach(() => { - clearAllPrompts() - $activeSessionId.set('sess-1') - $toolDisclosureStates.set({}) - clearDismissedToolRows() -}) - -afterEach(() => { - cleanup() - clearAllPrompts() - $activeSessionId.set(null) - clearDismissedToolRows() -}) - -describe('flat tool list approval surfacing', () => { - it('renders no inline approval bar when there is no live approval', async () => { - const { container } = render() - - // The pending terminal row mounts immediately, but its inline ApprovalBar - // returns null while $approvalRequest is empty. - await waitFor(() => { - expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0) - }) - expect(container.querySelector('[data-slot="tool-approval-inline"]')).toBeNull() - }) - - it('surfaces the approval inline and never under a hidden ancestor', async () => { - setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' }) - - const { container } = render() - - await waitFor(() => { - const bar = container.querySelector('[data-slot="tool-approval-inline"]') - expect(bar).not.toBeNull() - // Flat rows live directly in the flow — nothing should ever wrap the bar - // in a `hidden` subtree. - expect(bar?.closest('[hidden]')).toBeNull() - }) - }) - - it('lets completed tool rows be dismissed', async () => { - const { container } = render() - - const dismiss = await screen.findByLabelText('Dismiss') - - expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(1) - - fireEvent.click(dismiss) - - await waitFor(() => { - expect(screen.queryByLabelText('Dismiss')).toBeNull() - }) - }) - - it('keeps a dismissed row hidden after a remount (virtualization)', async () => { - // The thread virtualizes, so a row's component unmounts/remounts as it - // scrolls. Dismissal must persist across that — component-local state would - // forget it and the row would pop back. Simulate the remount by unmounting - // and rendering the same message fresh. - const first = render() - - fireEvent.click(await screen.findByLabelText('Dismiss')) - - await waitFor(() => { - expect(screen.queryByLabelText('Dismiss')).toBeNull() - }) - - first.unmount() - - const { container } = render() - - await waitFor(() => { - expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0) - }) - - expect(screen.queryByLabelText('Dismiss')).toBeNull() - }) - - it('lets failed tool rows be dismissed', async () => { - render() - - const dismiss = await screen.findByLabelText('Dismiss') - - fireEvent.click(dismiss) - - await waitFor(() => { - expect(screen.queryByLabelText('Dismiss')).toBeNull() - }) - }) - - it('does not show dismiss for pending tool rows', async () => { - const { container } = render() - - await waitFor(() => { - expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0) - }) - - expect(screen.queryByLabelText('Dismiss')).toBeNull() - }) -}) diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts index 6bcd06a63a93..5a7a5066c62d 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts @@ -65,7 +65,7 @@ function fileEditPath(args: Record, result: Record, keys: readonly string[]): string { +export function firstStringField(record: Record, keys: readonly string[]): string { for (const key of keys) { const value = record[key] 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 75bacb338a5d..abe38c26f7dd 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 13e7c7e8e7a8..966e977b177f 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, SCAFFOLD_META_CLASS, ScaffoldRow } from '@/components/chat/scaffold-row' import { ZoomableImage } from '@/components/chat/zoomable-image' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' @@ -40,11 +43,12 @@ import { normalize } from '@/lib/text' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' import { recordPreviewArtifact } from '@/store/preview-status' +import { sessionApprovalRequest } from '@/store/prompts' import { $toolInlineDiff } from '@/store/tool-diffs' import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss' import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view' -import { PendingToolApproval } from './approval' +import { APPROVAL_TOOLS, PendingToolApproval } from './approval' import { buildToolView, clampForDisplay, @@ -63,6 +67,7 @@ import { type ToolStatus, type ToolTitleAction } from './fallback-model' +import { isToolCallPart, summarizeToolRun } from './run-summary' // `true` when a ToolEntry is rendered inside an embedding wrapper that owns // the per-row chrome (timer / preview). The flat ToolGroupSlot sets this @@ -70,14 +75,11 @@ import { // future embedding surface. const ToolEmbedContext = createContext(false) -// Shared header chrome for tool rows. Both the single-tool DisclosureRow -// and the multi-tool group header pass through these constants so a -// "Patch" row and a "Tool actions · 2 steps" row are visually identical. -const TOOL_HEADER_TITLE_CLASS = +// A search hit's title is result *content* inside an expanded row, not one of +// the scaffolding lines, so it keeps the brighter secondary grey. +const SEARCH_HIT_TITLE_CLASS = 'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--ui-text-secondary)' -const TOOL_HEADER_DURATION_CLASS = 'shrink-0 text-[0.625rem] tabular-nums text-(--ui-text-tertiary)' - const TOOL_HEADER_SUBTITLE_CLASS = 'text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)' @@ -252,13 +254,13 @@ function SearchResultsList({ hits }: { hits: SearchResultRow[] }) {
  • {hit.url ? ( ) : ( - {trimmedTitle} + {trimmedTitle} )} {hit.snippet &&

    {hit.snippet}

    }
  • @@ -286,8 +288,8 @@ function ToolTitle({ return ( : undefined + isPending && !embedded ? : undefined // Once a turn has settled, a hover/focus-revealed dismiss lets the user clear // a completed/failed row that would otherwise sit at the tail of the chat. @@ -508,6 +510,7 @@ function ToolEntry({ part }: ToolEntryProps) { 'group/tool-block min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)', open && TOOL_EXPANDED_SHELL_CLASS )} + data-conversation-scaffold="" data-file-edit={isFileEdit && open ? '' : undefined} data-slot="tool-block" data-tool-open={open ? '' : undefined} @@ -532,7 +535,7 @@ function ToolEntry({ part }: ToolEntryProps) { status={leadingStatus(isPending, view.status)} /> - {!isPending && view.countLabel && {view.countLabel}} + {!isPending && view.countLabel && {view.countLabel}} {showDiffStats && diffStats && ( {diffStats.added > 0 && ( @@ -544,7 +547,7 @@ function ToolEntry({ part }: ToolEntryProps) { )} {!isFileEdit && !isPending && view.durationLabel && ( - {view.durationLabel} + {view.durationLabel} )} @@ -699,155 +702,260 @@ function TerminalTranscript({ command, exitCode }: TerminalTranscriptProps) { ) } -// A back-to-back run of this many tool calls collapses into the bounded, -// auto-scrolling window; fewer than this stays a plain inline stack. -const TOOL_GROUP_SCROLL_THRESHOLD = 3 - -// Tools whose body must never be trapped behind the window's max-height + -// fade mask. A run holding any of them stays a plain, fully-visible stack no -// matter how long it is. +// Tools that draw their own surface and must never be folded into a run's +// summary. Two kinds, for the same reason — the thing on screen IS the point: // -// This list is deliberately tiny. A row rendered by ToolEntry carries -// `data-tool-row`, and the `:has([data-tool-row][data-tool-open])` rule in -// styles.css lifts the cap whenever one is open — so anything ToolEntry -// renders takes care of itself. A code/diff row mounts open (see -// `defaultOpen`), lifting the cap the moment it appears; a collapsed row is a -// one-line status with no body in the DOM at all, so there is nothing to clip. +// - File edits are the deliverable, not scaffolding. The diff is what the +// user reviews, so it stays visible at its place in the turn, live and +// settled, the way a PR shows its changes. +// - `clarify` and `image_generate` bypass ToolEntry to render their own +// markup: a question the user has to answer, an image they asked for. // -// Only components that bypass ToolEntry need this opt-out: `clarify` and -// `image_generate` render their own markup, never emit `data-tool-row`, and so -// the CSS escape hatch can never reach them. -const UNBOUNDABLE_TOOLS = new Set(['clarify', 'image_generate']) +// Everything else is ephemeral activity — reads, searches, commands — which is +// what a run summarizes and what the live ticker cycles through. +const CARD_TOOLS = new Set(['clarify', 'image_generate']) -export function isUnboundableTool(toolName: string): boolean { - return UNBOUNDABLE_TOOLS.has(toolName) +export function isCardTool(toolName: string): boolean { + return CARD_TOOLS.has(toolName) || isFileEditTool(toolName) } -export function shouldBoundToolGroup(childCount: number, hasUnboundable: boolean) { - return childCount >= TOOL_GROUP_SCROLL_THRESHOLD && !hasUnboundable -} +export type RunItem = { end: number; kind: 'run'; start: number } | { index: number; kind: 'card' } -// Pin-to-bottom + top-fade for the bounded tool window. Pins the newest row on -// growth (a call lands or a row expands) unless the user scrolled up, and fades -// the top edge once anything sits above it. Mirrors ThinkingDisclosure's live -// preview. `enabled` is false for short runs, leaving the plain flat stack. -function useToolWindow(enabled: boolean) { - const scrollRef = useRef(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 - - const pin = (entries: readonly ResizeObserverEntry[]) => { - const height = entries[entries.length - 1]?.borderBoxSize?.[0]?.blockSize ?? -1 - const grew = height < 0 || height > lastHeight - lastHeight = height - - if (!grew) { - return - } - - if (stickRef.current) { - el.scrollTop = el.scrollHeight - } - - syncFade() - } - - // No sync pin() here: the observer's guaranteed initial delivery runs it - // inside RO timing (layout already clean, still before paint). A sync call - // at effect time reads scrollHeight while the commit's layout is dirty — - // one forced reflow per tool group, which cascades on a session switch. - const observer = new ResizeObserver(pin) - observer.observe(content) - - return () => observer.disconnect() - }, [enabled, syncFade]) - - return { contentRef, faded, onScroll, scrollRef } + return items } /** - * Flat, Cursor-style tool list. assistant-ui hands us a *range* of - * consecutive tool-call parts, but how that range is sliced is unstable: a - * live stream interleaves narration/reasoning between calls (many tiny - * ranges), while the settled message reconstructs every tool_call back-to-back - * (one big range). Rendering a "Tool actions · N steps" group off that range - * therefore reshuffled the whole turn the instant it settled. + * The live run, as one line. * - * So we still never *label* the group: each tool is a standalone row on the - * tight `--tool-row-gap` rhythm. Once a run reaches `TOOL_GROUP_SCROLL_THRESHOLD` - * rows it collapses into a fixed-height, auto-scrolling window so a long run - * doesn't shove the reply off screen; shorter runs are byte-identical to before. - * The DOM shape is the same either way — only classes flip — so a run that - * crosses the threshold mid-stream never remounts a row. `ToolEmbedContext` is - * false so every row owns its own chrome (timer / preview / copy / approval). + * A run in progress shows only what it is doing right now; each new action + * slides the one before it up and out of a single-line window, so a turn that + * touches thirty files reads as one line ticking over in place instead of a + * list growing down the page. When the run settles the ticker goes away and + * the summary above it is all that's left. + * + * Rows are clipped to a uniform line box so the reel's offset stays exact + * whatever a row happens to contain. + */ +function ToolRunTicker({ children }: { children: ReactNode }) { + const rows = Children.toArray(children) + + return ( +
    +
    + {rows.map((row, index) => ( +
    + {row} +
    + ))} +
    +
    + ) +} + +// The one grey line that stands in for a run of tool calls — "Explored 3 +// files, ran 5 commands". Live, it narrates in the present tense above the +// ticker and offers no toggle, since there is nothing settled to unfold yet. +function ToolRunHeader({ + live, + onToggle, + open, + summary +}: { + live: boolean + onToggle?: () => void + open: boolean + summary: string +}) { + return ( +
    + + + {live ? {summary} : summary} + + +
    + ) +} + +interface ToolRunState { + count: number + key: string + live: boolean + /** A call still awaiting a result that could be the one blocking on approval. */ + pendingApprovalTool: boolean + summary: string +} + +// assistant-ui compares selector results with `Object.is` and calls the +// selector on every store update, so returning a fresh object here would +// re-render the group on every text delta in the turn. The run only changes +// when a call arrives or one finishes; cache on exactly that. +function useToolRun(startIndex: number, endIndex: number): ToolRunState { + const cache = useRef(null) + + return useAuiState(state => { + const parts = state.message.parts + const tools = parts.slice(Math.max(0, startIndex), endIndex + 1).filter(isToolCallPart) + + // Live means the turn is still working and nothing has come after this run + // — not that some call is unresolved. Those differ in the gap between one + // call finishing and the next arriving, which for sequential calls is most + // of the run: it fell back to past tense there, unmounting the ticker and + // dropping its reel to the top instead of scrolling. + // + // The tail bound is what keeps this honest — a turn that ends, or an agent + // that moves on to later parts, leaves the run settled and collapsible. + const live = selectMessageRunning(state) && endIndex >= parts.length - 1 + + const signature = tools + .map(tool => `${tool.toolCallId}:${tool.result === undefined ? 0 : 1}`) + .concat(String(live)) + .join('|') + + if (cache.current?.signature !== signature) { + cache.current = { + signature, + value: { + count: tools.length, + key: tools[0]?.toolCallId ?? '', + live, + pendingApprovalTool: tools.some(tool => tool.result === undefined && APPROVAL_TOOLS.has(tool.toolName)), + summary: summarizeToolRun(tools, live) + } + } + } + + return cache.current.value + }) +} + +/** + * One run of consecutive activity calls, headed by the line that summarizes it. + * + * The run is identified by its FIRST tool call, never by its position: a live + * stream and the same turn rehydrated from history agree on which calls belong + * together, but not on the indices they land at, because rehydration folds a + * turn into one bubble that the live view spreads over several. Keying off the + * index is what made an earlier attempt at this reshuffle the moment a turn + * settled. `lib/tool-run-continuity.test.ts` locks that agreement down. + * + * Live, the run is a summary plus the one-line ticker. Settled, the summary is + * the whole of it until the user opens it. `ToolEmbedContext` is false so each + * row still owns its own chrome (timer / copy / approval) when shown. + */ +const ToolRun: FC> = ({ + 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 messageId = useAuiState(s => s.message.id) - const messageRunning = useAuiState(selectMessageRunning) - - const hasUnboundable = useAuiState(s => - s.message.parts + // Joined rather than returned as an array: assistant-ui compares selector + // results with `Object.is` and re-runs them on every store update, so a + // fresh array would re-render the whole group on every text delta. + const toolNameKey = useAuiState(state => + state.message.parts .slice(Math.max(0, startIndex), endIndex + 1) - .some(part => part.type === 'tool-call' && isUnboundableTool(part.toolName)) + .map(part => (part.type === 'tool-call' ? part.toolName : '')) + .join('\u0000') ) - const enterRef = useEnterAnimation(messageRunning, `tool-group:${messageId}:${startIndex}`) - - const bounded = shouldBoundToolGroup(Children.count(children), hasUnboundable) - const { contentRef, faded, onScroll, scrollRef } = useToolWindow(bounded) + const items = useMemo(() => splitRunItems(toolNameKey.split('\u0000')), [toolNameKey]) + const rows = Children.toArray(children) return ( -
    -
    -
    - {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.test.ts b/apps/desktop/src/components/assistant-ui/tool/run-summary.test.ts new file mode 100644 index 000000000000..ba47368a031f --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool/run-summary.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' + +import { summarizeToolRun, type ToolCallLike } from './run-summary' + +function tool(toolName: string, args: Record = {}, result?: unknown): ToolCallLike { + return { args, result, toolCallId: `${toolName}-${Math.random()}`, toolName } +} + +const read = (path: string) => tool('read_file', { path }, { content: '' }) +const searched = (query: string) => tool('search_files', { query }, { hits: [] }) +const ran = (command: string) => tool('terminal', { command }, { exit_code: 0 }) + +const settled = (tools: ToolCallLike[]) => summarizeToolRun(tools, false) +const running = (tools: ToolCallLike[]) => summarizeToolRun(tools, true) + +// A run only ever holds ephemeral activity: reads, searches, commands. File +// edits and other cards are split out before a run is summarized, so there is +// no "Edited …" clause to test here — that work shows as its own diff card. +describe('summarizeToolRun', () => { + it('names a lone target and counts the rest', () => { + expect(settled([searched('toolRuns'), read('a.ts'), read('b.ts'), read('c.ts')])).toBe('Explored 4 files') + }) + + it('orders clauses explore then run regardless of call order', () => { + expect(settled([ran('ls'), read('a.ts'), read('b.ts'), ran('pwd'), ran('id')])).toBe( + 'Explored 2 files, ran 3 commands' + ) + }) + + it('counts commands rather than naming them once they have run', () => { + expect(settled([ran('git status')])).toBe('Ran 1 command') + expect(settled([read('status.ts'), ran('a'), ran('b'), ran('c'), ran('d'), ran('e')])).toBe( + 'Explored status.ts, ran 5 commands' + ) + }) + + it('puts the running category in the present tense and leaves the rest past', () => { + expect(running([read('a.ts'), tool('read_file', { path: 'b.ts' }), ran('x'), ran('y')])).toBe( + 'Exploring 2 files, ran 2 commands' + ) + }) + + it('names the command that is still running', () => { + expect(running([tool('terminal', { command: 'npm run typecheck' })])).toMatch(/^Running /) + }) + + // Sequential calls leave a gap where the run is still going but nothing is + // pending. Falling back to past tense there contradicted the ticker still + // scrolling underneath, so the most recent call carries the present tense. + it('stays in the present tense between two sequential calls', () => { + expect(running([read('a.ts'), ran('x'), ran('y')])).toBe('Explored a.ts, running 2 commands') + }) + + // A turn can end — or the agent can simply move on — with a call that never + // got a result. The run is history at that point and has to read as history, + // or it narrates work that stopped happening and never offers its toggle. + it('reads a run the turn left unresolved as finished', () => { + expect(settled([read('a.ts'), tool('search_files', { query: 'toolRuns' })])).toBe('Explored 2 files') + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/tool/run-summary.ts b/apps/desktop/src/components/assistant-ui/tool/run-summary.ts new file mode 100644 index 000000000000..6abf559a33f3 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool/run-summary.ts @@ -0,0 +1,156 @@ +import { summarizeShellCommand } from '@/lib/summarize-command' + +import { fileEditBasename, firstStringField, isFileEditTool, parseMaybeObject } from './fallback-model' + +/** + * The little a summary needs from a tool call, stated structurally so both + * shapes of tool part satisfy it — the stored `ChatMessagePart` and the live + * one assistant-ui hands to a renderer. + */ +export interface ToolCallLike { + args?: unknown + result?: unknown + toolCallId?: string + toolName: string +} + +export function isToolCallPart(part: T): part is Extract { + return part.type === 'tool-call' +} + +type RunCategory = 'delegate' | 'edit' | 'explore' | 'other' | 'run' + +// Clause order is fixed so the same run always reads the same way, whichever +// category happens to be live. +const CATEGORY_ORDER: readonly RunCategory[] = ['edit', 'explore', 'run', 'delegate', 'other'] + +const CATEGORY_COPY: Record = { + delegate: { noun: ['task', 'tasks'], past: 'Delegated', present: 'Delegating' }, + edit: { noun: ['file', 'files'], past: 'Edited', present: 'Editing' }, + explore: { noun: ['file', 'files'], past: 'Explored', present: 'Exploring' }, + other: { noun: ['tool', 'tools'], past: 'Used', present: 'Using' }, + run: { noun: ['command', 'commands'], past: 'Ran', present: 'Running' } +} + +const EXPLORE_TOOLS = new Set([ + 'list_files', + 'read_file', + 'search_files', + 'session_search_recall', + 'vision_analyze', + 'web_extract', + 'web_search' +]) + +function toolCategory(toolName: string): RunCategory { + if (isFileEditTool(toolName)) { + return 'edit' + } + + if (toolName === 'terminal' || toolName === 'execute_code') { + return 'run' + } + + if (toolName === 'delegate_task') { + return 'delegate' + } + + if (EXPLORE_TOOLS.has(toolName) || toolName.startsWith('browser_')) { + return 'explore' + } + + return 'other' +} + +function isPending(tool: ToolCallLike): boolean { + return tool.result === undefined +} + +/** + * How a tool reads while it is happening — "Editing", "Exploring". Shared with + * the status line that covers the gap before a tool starts, so the same run is + * described in the same words from the moment the model drafts it. + */ +export function toolPresentVerb(toolName: string): string { + return CATEGORY_COPY[toolCategory(toolName)].present +} + +/** The thing a tool acted on, as the header should name it. */ +function toolTarget(tool: ToolCallLike): string { + const args = parseMaybeObject(tool.args) + + if (toolCategory(tool.toolName) === 'run') { + return summarizeShellCommand(firstStringField(args, ['command', 'code'])) + } + + const path = firstStringField(args, ['path', 'file', 'filepath']) + + return path ? fileEditBasename(path) : firstStringField(args, ['query', 'url']) +} + +/** + * One clause per category. A category holding a single thing says what it was + * ("Edited wiring.tsx"); anything else counts ("explored 3 files"). A settled + * command is the exception — "ran 5 commands" is the useful reading, and a + * command line only earns its space while it's the thing you're waiting on. + */ +function clause(category: RunCategory, tools: ToolCallLike[], live: boolean): string { + const copy = CATEGORY_COPY[category] + const verb = live ? copy.present : copy.past + const target = tools.length === 1 ? toolTarget(tools[0]) : '' + + if (target && (live || category !== 'run')) { + return `${verb} ${target}` + } + + return `${verb} ${tools.length} ${copy.noun[tools.length === 1 ? 0 : 1]}` +} + +function lowerFirst(text: string): string { + return text.charAt(0).toLowerCase() + text.slice(1) +} + +/** + * Collapse a run of tool calls into the single grey line that stands in for it + * — "Explored 3 files, ran 5 commands". While the run is live, the category + * holding its most recent call speaks in the present tense so the line reads as + * work in progress rather than work already done. + * + * Whether the run is `live` is the caller's to say, not something readable off + * the calls: a call can be left without a result by a turn that ended or an + * agent that moved on, and a run like that has to read as finished rather than + * narrate work that stopped happening. + * + * A run only ever holds ephemeral activity — file edits and other cards are + * split out before this sees them (`splitRunItems`), so there is no aggregate + * diff to report here; each edit carries its own +N/−M on its card. + */ +export function summarizeToolRun(tools: readonly ToolCallLike[], live: boolean): string { + // Which clause narrates in the present tense: normally the outstanding call, + // but sequential calls leave gaps where the run is still going and nothing is + // pending. The most recent call covers those, and it's the one the ticker is + // showing anyway. + const narrating = live ? (tools.find(isPending) ?? tools.at(-1)) : undefined + const liveCategory = narrating ? toolCategory(narrating.toolName) : null + + const byCategory = new Map() + + for (const tool of tools) { + const category = toolCategory(tool.toolName) + const group = byCategory.get(category) + + if (group) { + group.push(tool) + } else { + byCategory.set(category, [tool]) + } + } + + const clauses = CATEGORY_ORDER.flatMap(category => { + const group = byCategory.get(category) + + return group ? [clause(category, group, category === liveCategory)] : [] + }) + + return clauses.map((text, index) => (index === 0 ? text : lowerFirst(text))).join(', ') +} diff --git a/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx b/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx new file mode 100644 index 000000000000..65e9ab34d078 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx @@ -0,0 +1,633 @@ +import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { clearAllPrompts, setApprovalRequest } from '@/store/prompts' +import { $activeSessionId } from '@/store/session' +import { clearDismissedToolRows } from '@/store/tool-dismiss' +import { $toolDisclosureStates } from '@/store/tool-view' + +import { Thread } from '../thread' + +// A run of tool calls collapses to a one-line summary once it has settled, but +// a run with anything still pending always renders its rows. That rule is what +// keeps the "approval must never be buried" bug fixed: an inline ApprovalBar +// only ever exists on a pending tool, and a pending tool's run is never behind +// a chevron. These cover both halves — the collapse itself, and the approval +// staying in the visual flow. + +const createdAt = new Date('2026-06-03T00:00:00.000Z') + +const resizeObservers = new Set() + +class TestResizeObserver { + private target: Element | null = null + + constructor(private readonly callback: ResizeObserverCallback) { + resizeObservers.add(this) + } + + observe(target: Element) { + this.target = target + } + + unobserve() {} + + disconnect() { + resizeObservers.delete(this) + } +} + +vi.stubGlobal('ResizeObserver', TestResizeObserver) +vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0) +) +vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) + +Element.prototype.scrollTo = function scrollTo() {} + +Element.prototype.animate = function animate() { + return { + cancel: () => {}, + finished: Promise.resolve() + } as unknown as Animation +} + +function stubOffsetDimension( + prop: 'offsetHeight' | 'offsetWidth', + clientProp: 'clientHeight' | 'clientWidth', + fallback: number +) { + const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop) + + Object.defineProperty(HTMLElement.prototype, prop, { + configurable: true, + get() { + return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback + } + }) +} + +stubOffsetDimension('offsetWidth', 'clientWidth', 800) +stubOffsetDimension('offsetHeight', 'clientHeight', 600) + +// A running assistant message with two tools: a completed read_file plus a +// pending terminal (no result), rendered as a flat two-row list. +function groupedPendingMessage(): ThreadMessage { + return { + id: 'assistant-group-1', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'read-1', + toolName: 'read_file', + args: { path: '/etc/hosts' }, + argsText: JSON.stringify({ path: '/etc/hosts' }), + result: { content: '127.0.0.1 localhost' } + }, + { + type: 'tool-call', + toolCallId: 'term-1', + toolName: 'terminal', + args: { command: 'rm -rf /tmp/x' }, + argsText: JSON.stringify({ command: 'rm -rf /tmp/x' }) + } + ], + status: { type: 'running' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +function pendingOnlyMessage(): ThreadMessage { + return { + id: 'assistant-pending-only', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'term-only', + toolName: 'terminal', + args: { command: 'sleep 10' }, + argsText: JSON.stringify({ command: 'sleep 10' }) + } + ], + status: { type: 'running' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +function completedOnlyMessage(): ThreadMessage { + return { + id: 'assistant-completed-only', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'read-only', + toolName: 'read_file', + args: { path: '/etc/hosts' }, + argsText: JSON.stringify({ path: '/etc/hosts' }), + result: { content: '127.0.0.1 localhost' } + } + ], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +function failedOnlyMessage(): ThreadMessage { + return { + id: 'assistant-failed-only', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'term-failed', + toolName: 'terminal', + args: { command: 'exit 1' }, + argsText: JSON.stringify({ command: 'exit 1' }), + isError: true, + result: { stderr: 'boom' } + } + ], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +// Two settled activity calls in a row, so the run earns a summary line and +// collapses behind it. +function settledRunMessage(): ThreadMessage { + return { + id: 'assistant-settled-run', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'read-2', + toolName: 'read_file', + args: { path: '/repo/src/wiring.tsx' }, + argsText: JSON.stringify({ path: '/repo/src/wiring.tsx' }), + result: { content: 'export const Wiring = () => null' } + }, + { + type: 'tool-call', + toolCallId: 'term-3', + toolName: 'terminal', + args: { command: 'ls -la' }, + argsText: JSON.stringify({ command: 'ls -la' }), + result: { exit_code: 0, stdout: 'wiring.tsx' } + } + ], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +// Activity, an edit, then more activity — all adjacent, so assistant-ui hands +// the whole stretch over as one group. The edit is the deliverable and has to +// survive that as its own card. +function editBetweenRunsMessage(): ThreadMessage { + return { + id: 'assistant-edit-between-runs', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'read-5', + toolName: 'read_file', + args: { path: '/repo/src/a.ts' }, + argsText: JSON.stringify({ path: '/repo/src/a.ts' }), + result: { content: 'a' } + }, + { + type: 'tool-call', + toolCallId: 'search-3', + toolName: 'search_files', + args: { query: 'toolRuns' }, + argsText: JSON.stringify({ query: 'toolRuns' }), + result: { hits: [] } + }, + { + type: 'tool-call', + toolCallId: 'patch-2', + toolName: 'patch', + args: { path: '/repo/src/wiring.tsx' }, + argsText: JSON.stringify({ path: '/repo/src/wiring.tsx' }), + result: { path: '/repo/src/wiring.tsx', inline_diff: '--- a\n+++ b\n+added line\n-removed line' } + }, + { + type: 'tool-call', + toolCallId: 'read-6', + toolName: 'read_file', + args: { path: '/repo/src/b.ts' }, + argsText: JSON.stringify({ path: '/repo/src/b.ts' }), + result: { content: 'b' } + }, + { + type: 'tool-call', + toolCallId: 'term-4', + toolName: 'terminal', + args: { command: 'ls' }, + argsText: JSON.stringify({ command: 'ls' }), + result: { exit_code: 0 } + } + ], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +// A finished turn that left a call without a result — interrupted, or the +// result landed elsewhere. The run is history and has to behave like it. +function abandonedRunMessage(): ThreadMessage { + return { + id: 'assistant-abandoned-run', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'read-3', + toolName: 'read_file', + args: { path: '/repo/src/status.tsx' }, + argsText: JSON.stringify({ path: '/repo/src/status.tsx' }), + result: { content: 'export const Status = () => null' } + }, + { + type: 'tool-call', + toolCallId: 'search-1', + toolName: 'search_files', + args: { query: 'toolRuns' }, + argsText: JSON.stringify({ query: 'toolRuns' }) + } + ], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +// The gap between one sequential call finishing and the next arriving: the +// turn is still running, the run is still the tail, but for this instant every +// call has a result. +function betweenSequentialCallsMessage(): ThreadMessage { + return { + id: 'assistant-between-calls', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'term-a', + toolName: 'terminal', + args: { command: 'sleep 2; echo alpha' }, + argsText: JSON.stringify({ command: 'sleep 2; echo alpha' }), + result: { exit_code: 0, stdout: 'alpha' } + }, + { + type: 'tool-call', + toolCallId: 'term-b', + toolName: 'terminal', + args: { command: 'sleep 2; echo bravo' }, + argsText: JSON.stringify({ command: 'sleep 2; echo bravo' }), + result: { exit_code: 0, stdout: 'bravo' } + } + ], + status: { type: 'running' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +// Still streaming, but the agent has moved past its first run and left both of +// its calls unresolved. Only the run at the tail is still live. +function movedOnMessage(): ThreadMessage { + return { + id: 'assistant-moved-on', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'read-4', + toolName: 'read_file', + args: { path: '/repo/src/status.tsx' }, + argsText: JSON.stringify({ path: '/repo/src/status.tsx' }) + }, + { + type: 'tool-call', + toolCallId: 'search-2', + toolName: 'search_files', + args: { query: 'toolRuns' }, + argsText: JSON.stringify({ query: 'toolRuns' }) + }, + { type: 'text', text: 'Let me read the rest of the file.' }, + { + type: 'tool-call', + toolCallId: 'term-2', + toolName: 'terminal', + args: { command: 'ls' }, + argsText: JSON.stringify({ command: 'ls' }) + } + ], + status: { type: 'running' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +function GroupHarness({ message }: { message: ThreadMessage }) { + const runtime = useExternalStoreRuntime({ + messages: [message], + isRunning: message.status?.type === 'running', + onNew: async () => {} + }) + + return ( + + + + ) +} + +beforeEach(() => { + clearAllPrompts() + $activeSessionId.set('sess-1') + $toolDisclosureStates.set({}) + clearDismissedToolRows() +}) + +afterEach(() => { + cleanup() + clearAllPrompts() + $activeSessionId.set(null) + clearDismissedToolRows() +}) + +describe('settled tool run', () => { + it('collapses to a summary line naming the work', async () => { + const { container } = render() + + expect(await screen.findByText('Explored wiring.tsx, ran 1 command')).toBeTruthy() + expect(container.querySelectorAll('[data-tool-row]')).toHaveLength(0) + }) + + it('expands to the underlying rows when the summary is clicked', async () => { + const { container } = render() + + fireEvent.click(await screen.findByText('Explored wiring.tsx, ran 1 command')) + + await waitFor(() => { + expect(container.querySelectorAll('[data-tool-row]').length).toBeGreaterThan(0) + }) + }) + + it('leaves a lone tool call as its own row, with no summary above it', async () => { + const { container } = render() + + await waitFor(() => { + expect(container.querySelectorAll('[data-tool-row]').length).toBe(1) + }) + expect(container.querySelector('[data-tool-summary]')).toBeNull() + }) +}) + +// A diff is what the user reviews, so it is never what gets summarized away. +// It stays on screen at the point in the turn where it happened, with the +// activity either side of it collapsing around it. +describe('a file edit among ordinary activity', () => { + it('stays visible between the two runs it interrupted', async () => { + const { container } = render() + + await screen.findByText('Explored 2 files') + + const shape = [...container.querySelectorAll('[data-tool-summary],[data-tool-row]')].map(node => + node.hasAttribute('data-tool-summary') ? 'summary' : 'row' + ) + + expect(shape).toEqual(['summary', 'row', 'summary']) + }) + + it('keeps the diff itself on screen rather than behind the summary', async () => { + const { container } = render() + + await waitFor(() => { + expect(container.querySelector('[data-tool-row][data-file-edit]')).not.toBeNull() + }) + }) +}) + +// The transcript rests its scaffolding at a fade, keyed off one attribute. A +// surface that renders without it is brighter than everything around it, which +// is how two adjacent, identical rows came to sit at two opacities. +describe('transcript fade', () => { + it('marks every row and summary as scaffolding', async () => { + const { container } = render() + + await screen.findByText('Explored 2 files') + + const unmarked = [...container.querySelectorAll('[data-tool-summary],[data-tool-row]')].filter( + node => !node.hasAttribute('data-conversation-scaffold') + ) + + expect(unmarked).toHaveLength(0) + }) +}) + +describe('live tool run', () => { + it('keeps its rows on screen instead of hiding them behind the summary', async () => { + const { container } = render() + + await waitFor(() => { + expect(container.querySelectorAll('[data-tool-row]').length).toBeGreaterThan(0) + }) + }) + + it('cannot be collapsed while a tool is still running', async () => { + const { container } = render() + + await waitFor(() => { + expect(container.querySelector('[data-tool-summary]')).not.toBeNull() + }) + + expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).toBeNull() + }) + + // Liveness used to also require an unresolved call, which is false for the + // instant between one sequential call finishing and the next arriving — so a + // string of commands settled and re-opened between every one, unmounting the + // ticker and dropping its reel back to the first row instead of scrolling. + it('stays live in the gap between two sequential calls', async () => { + const { container } = render() + + expect(await screen.findByText('Running 2 commands')).toBeTruthy() + expect(container.querySelector('[data-tool-ticker]')).not.toBeNull() + expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).toBeNull() + }) +}) + +// A run whose calls never resolved used to read as live forever, which stranded +// it in the present tense and — because a live run withholds its toggle — left +// it permanently expanded with no way to collapse it. +describe('tool run left unresolved', () => { + it('settles with the turn rather than narrating work that stopped', async () => { + const { container } = render() + + expect(await screen.findByText('Explored 2 files')).toBeTruthy() + expect(container.querySelectorAll('[data-tool-row]')).toHaveLength(0) + expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).not.toBeNull() + }) + + it('settles once the agent moves on, even mid-turn', async () => { + const { container } = render() + + expect(await screen.findByText('Explored 2 files')).toBeTruthy() + expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).not.toBeNull() + }) +}) + +describe('flat tool list approval surfacing', () => { + it('renders no inline approval bar when there is no live approval', async () => { + const { container } = render() + + // The pending terminal row mounts immediately, but its inline ApprovalBar + // returns null while $approvalRequest is empty. + await waitFor(() => { + expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0) + }) + expect(container.querySelector('[data-slot="tool-approval-inline"]')).toBeNull() + }) + + it('surfaces the approval inline and never under a hidden ancestor', async () => { + setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' }) + + const { container } = render() + + await waitFor(() => { + const bar = container.querySelector('[data-slot="tool-approval-inline"]') + expect(bar).not.toBeNull() + // Flat rows live directly in the flow — nothing should ever wrap the bar + // in a `hidden` subtree. + expect(bar?.closest('[hidden]')).toBeNull() + }) + }) + + it('lets completed tool rows be dismissed', async () => { + const { container } = render() + + const dismiss = await screen.findByLabelText('Dismiss') + + expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0) + + fireEvent.click(dismiss) + + await waitFor(() => { + expect(screen.queryByLabelText('Dismiss')).toBeNull() + }) + }) + + it('keeps a dismissed row hidden after a remount (virtualization)', async () => { + // The thread virtualizes, so a row's component unmounts/remounts as it + // scrolls. Dismissal must persist across that — component-local state would + // forget it and the row would pop back. Simulate the remount by unmounting + // and rendering the same message fresh. + const first = render() + + fireEvent.click(await screen.findByLabelText('Dismiss')) + + await waitFor(() => { + expect(screen.queryByLabelText('Dismiss')).toBeNull() + }) + + first.unmount() + + render() + + // The row is the only thing this message renders, so staying dismissed + // means nothing comes back — including its dismiss control. + await waitFor(() => { + expect(screen.queryByLabelText('Dismiss')).toBeNull() + }) + }) + + it('lets failed tool rows be dismissed', async () => { + render() + + const dismiss = await screen.findByLabelText('Dismiss') + + fireEvent.click(dismiss) + + await waitFor(() => { + expect(screen.queryByLabelText('Dismiss')).toBeNull() + }) + }) + + it('does not show dismiss for pending tool rows', async () => { + const { container } = render() + + await waitFor(() => { + expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0) + }) + + expect(screen.queryByLabelText('Dismiss')).toBeNull() + }) +}) diff --git a/apps/desktop/src/components/chat/activity-timer.test.tsx b/apps/desktop/src/components/chat/activity-timer.test.tsx index 4768f60c56ef..02be87985684 100644 --- a/apps/desktop/src/components/chat/activity-timer.test.tsx +++ b/apps/desktop/src/components/chat/activity-timer.test.tsx @@ -1,7 +1,7 @@ import { act, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { __resetElapsedTimerRegistryForTests, useElapsedSeconds } from './activity-timer' +import { __resetElapsedTimerRegistryForTests, useElapsedSeconds, useMeasuredDuration } from './activity-timer' function Probe({ active, since, timerKey }: { active: boolean; since?: number; timerKey?: string }) { const elapsed = useElapsedSeconds(active, timerKey, since) @@ -9,6 +9,12 @@ function Probe({ active, since, timerKey }: { active: boolean; since?: number; t return {elapsed} } +function DurationProbe({ active, timerKey }: { active: boolean; timerKey: string }) { + const measured = useMeasuredDuration(active, timerKey) + + return {measured === null ? 'unknown' : measured} +} + describe('useElapsedSeconds', () => { beforeEach(() => { vi.useFakeTimers() @@ -67,3 +73,84 @@ describe('useElapsedSeconds', () => { expect(screen.getByTestId('elapsed').textContent).toBe('0') }) }) + +describe('useMeasuredDuration', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + __resetElapsedTimerRegistryForTests() + }) + + afterEach(() => { + vi.useRealTimers() + __resetElapsedTimerRegistryForTests() + }) + + it('has nothing to report until it has watched something finish', () => { + render() + + act(() => { + vi.advanceTimersByTime(4_000) + }) + + expect(screen.getByTestId('measured').textContent).toBe('unknown') + }) + + it('freezes the duration at the moment the thing finishes', () => { + const { rerender } = render() + + act(() => { + vi.advanceTimersByTime(4_000) + }) + + rerender() + + expect(screen.getByTestId('measured').textContent).toBe('4') + + // Time keeps passing; the block is over and its duration must not creep up + // with it. + act(() => { + vi.advanceTimersByTime(9_000) + }) + + expect(screen.getByTestId('measured').textContent).toBe('4') + }) + + // The thread virtualizes, so the component that watched a block finish is + // usually gone by the time anyone scrolls back to read it. + it('remembers the duration for a component that mounts after the fact', () => { + const first = render() + + act(() => { + vi.advanceTimersByTime(6_000) + }) + + first.rerender() + first.unmount() + + render() + + expect(screen.getByTestId('measured').textContent).toBe('6') + }) + + it('measures each key separately', () => { + const first = render() + + act(() => { + vi.advanceTimersByTime(3_000) + }) + + first.rerender() + first.unmount() + + const second = render() + + act(() => { + vi.advanceTimersByTime(2_000) + }) + + second.rerender() + + expect(screen.getByTestId('measured').textContent).toBe('2') + }) +}) diff --git a/apps/desktop/src/components/chat/activity-timer.ts b/apps/desktop/src/components/chat/activity-timer.ts index 9fe67642239d..6eddba167fbf 100644 --- a/apps/desktop/src/components/chat/activity-timer.ts +++ b/apps/desktop/src/components/chat/activity-timer.ts @@ -5,6 +5,10 @@ import { useEffect, useRef, useState } from 'react' // anonymous timers (no key) start fresh each mount. const startedAtByKey = new Map() +// Durations of things that have already finished, kept beside the origins that +// measured them. See `useMeasuredDuration`. +const durationByKey = new Map() + function startedAt(key?: string): number { if (!key) { return Date.now() @@ -71,6 +75,41 @@ export function useElapsedSeconds(active = true, timerKey?: string, since?: numb return elapsed } +/** + * How long something took, measured by watching it finish and remembered + * afterwards. `null` until it has been watched at least once. + * + * Some durations exist nowhere but in the watching. A reasoning block is the + * case this was written for: the persisted turn records the text the model + * thought, never how long it spent thinking it, so the only way to know is to + * have been there. Watching alone isn't enough either — the thread virtualizes, + * so the component that saw a block finish is usually gone by the time anyone + * scrolls back to read it. Keeping the number in the same registry as the + * timer's origin lets it outlive the component that measured it. + * + * A block that was never watched running — history loaded from an earlier app + * session, or reasoning that arrived already complete — has no duration and + * says so, rather than reporting a timer that never ran. + */ +export function useMeasuredDuration(active: boolean, timerKey: string): null | number { + const elapsed = useElapsedSeconds(active, timerKey) + const [watching, setWatching] = useState(false) + const [measured, setMeasured] = useState(() => durationByKey.get(timerKey) ?? null) + + useEffect(() => { + if (active) { + setWatching(true) + } else if (watching) { + setWatching(false) + durationByKey.set(timerKey, elapsed) + setMeasured(elapsed) + } + }, [active, elapsed, timerKey, watching]) + + return measured +} + export function __resetElapsedTimerRegistryForTests() { startedAtByKey.clear() + durationByKey.clear() } 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 000000000000..23e0c737a782 --- /dev/null +++ b/apps/desktop/src/components/chat/scaffold-row.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from 'react' + +import { DisclosureRow } from '@/components/chat/disclosure-row' + +/** + * Transcript scaffolding: the quiet lines around the reply that say what the + * agent did rather than what it said — a thinking header, a settled tool run, + * the live activity ticker. + * + * They all render through here so they cannot drift apart. They used to each + * pick their own grey — a thinking header painted `--ui-text-secondary`, a tool + * summary `--ui-text-tertiary`, both under the same opacity — which read as two + * different kinds of line for what is one kind of thing. + * + * The resting fade is the other half and lives in CSS, on the *block* that + * holds the row rather than on the row: mark it `data-conversation-scaffold`. + * A surface that skips the mark reads a shade brighter than its neighbours. + */ +export const SCAFFOLD_LABEL_CLASS = + 'text-[length:var(--conversation-tool-font-size)] leading-(--conversation-line-height) text-(--conversation-scaffold-text)' + +/** Durations, counts and diff stats trailing a scaffold label. */ +export const SCAFFOLD_META_CLASS = 'shrink-0 text-[0.625rem] tabular-nums text-(--conversation-scaffold-meta)' + +/** + * One scaffold line. `children` is the label and whatever trails it in flow + * (meta, diff counts); `trailing` overlays the right edge for a live timer. + */ +export function ScaffoldRow({ + children, + onToggle, + open = false, + trailing +}: { + children: ReactNode + onToggle?: () => void + open?: boolean + trailing?: ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 75dd38cd937b..2b1e8bd42b5f 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -2273,6 +2273,9 @@ export const ar = defineLocale({ resumeWhenBackgroundDone: count => count === 1 ? 'سيُستأنف عند انتهاء المهمة الخلفية' : `سيُستأنف عند انتهاء ${count} مهام خلفية`, thinking: 'يفكر...', + thought: 'فكّر', + thoughtBriefly: 'فكّر قليلاً', + thoughtFor: duration => `فكّر لمدة ${duration}`, today: time => `اليوم ${time}`, yesterday: time => `أمس ${time}`, copy: 'نسخ', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 360cb1ab7c23..041b2bf35cc4 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2670,6 +2670,9 @@ export const en: Translations = { ? 'Will resume when the background task finishes' : `Will resume when ${count} background tasks finish`, thinking: 'Thinking', + thought: 'Thought', + thoughtBriefly: 'Thought briefly', + thoughtFor: duration => `Thought for ${duration}`, today: time => `Today, ${time}`, yesterday: time => `Yesterday, ${time}`, copy: 'Copy', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 3783e47ff0da..6971ab4b5ccd 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2517,6 +2517,9 @@ export const ja = defineLocale({ ? 'バックグラウンドタスクの完了後に再開します' : `${count} 件のバックグラウンドタスクの完了後に再開します`, thinking: '考え中', + thought: '思考済み', + thoughtBriefly: '少し思考', + thoughtFor: duration => `${duration} 思考`, today: time => `今日 ${time}`, yesterday: time => `昨日 ${time}`, copy: 'コピー', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 2b62a2aaf2c4..6a4d97b02164 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2268,6 +2268,9 @@ export interface Translations { loadingResponse: string resumeWhenBackgroundDone: (count: number) => string thinking: string + thought: string + thoughtBriefly: string + thoughtFor: (duration: string) => string today: (time: string) => string yesterday: (time: string) => string copy: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index c785aaebb421..e4c96d779fff 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -2438,6 +2438,9 @@ export const zhHant = defineLocale({ resumeWhenBackgroundDone: count => count === 1 ? '背景工作完成後將自動繼續' : `${count} 個背景工作完成後將自動繼續`, thinking: '思考中', + thought: '已思考', + thoughtBriefly: '思考了片刻', + thoughtFor: duration => `思考了 ${duration}`, today: time => `今天,${time}`, yesterday: time => `昨天,${time}`, copy: '複製', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 491bf53cc9be..20c4d08f8fc2 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2841,6 +2841,9 @@ export const zh: Translations = { resumeWhenBackgroundDone: count => count === 1 ? '后台任务完成后将自动继续' : `${count} 个后台任务完成后将自动继续`, thinking: '思考中', + thought: '已思考', + thoughtBriefly: '思考了片刻', + thoughtFor: duration => `思考了 ${duration}`, today: time => `今天,${time}`, yesterday: time => `昨天,${time}`, copy: '复制', diff --git a/apps/desktop/src/lib/tool-run-continuity.test.ts b/apps/desktop/src/lib/tool-run-continuity.test.ts new file mode 100644 index 000000000000..a009053db3da --- /dev/null +++ b/apps/desktop/src/lib/tool-run-continuity.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from 'vitest' + +import { isToolCallPart, type ToolCallLike } from '@/components/assistant-ui/tool/run-summary' +import type { SessionMessage } from '@/types/hermes' + +import type { ChatMessage, ChatMessagePart } from './chat-messages' +import { + appendAssistantTextPart, + appendReasoningPart, + assistantTextPart, + mergeFinalAssistantText, + toChatMessages, + upsertToolPart +} from './chat-messages' +import { coalesceToolOnlyAssistants, createToolMergeCache } from './chat-runtime' + +/** + * A turn described once, replayed two ways: as the gateway event stream the + * live view builds bubbles from, and as the persisted rows `toChatMessages` + * rehydrates on resume. Grouping is only stable if both produce the same runs. + */ +type TurnStep = + | { kind: 'interim'; text: string } + | { kind: 'final'; text: string } + | { kind: 'reasoning'; text: string } + | { kind: 'text'; text: string } + | { kind: 'tool'; id: string; name: string } + +// Mirrors use-message-stream: deltas and tool events accumulate on one pending +// bubble; `message.interim` seals it in place and starts a fresh one; and +// `message.complete` merges the final text onto whatever bubble is open. +function replayLive(steps: TurnStep[]): ChatMessage[] { + const messages: ChatMessage[] = [] + let streamIndex = 0 + let open: ChatMessage | null = null + + const openBubble = (): ChatMessage => { + if (open) { + return open + } + + streamIndex += 1 + open = { id: `assistant-stream-${streamIndex}`, role: 'assistant', parts: [], pending: true } + messages.push(open) + + return open + } + + const seal = (text: string, interim: boolean) => { + const bubble = open ?? openBubble() + + bubble.parts = mergeFinalAssistantText(bubble.parts, text) + bubble.pending = false + bubble.interim = interim + open = null + } + + for (const step of steps) { + switch (step.kind) { + case 'interim': + seal(step.text, true) + + break + + case 'final': + seal(step.text, false) + + break + case 'reasoning': { + const bubble = openBubble() + + bubble.parts = appendReasoningPart(bubble.parts, step.text) + + break + } + + case 'text': { + const bubble = openBubble() + + bubble.parts = appendAssistantTextPart(bubble.parts, step.text) + + break + } + + case 'tool': { + const bubble = openBubble() + + bubble.parts = upsertToolPart(bubble.parts, { tool_id: step.id, name: step.name }, 'complete') + + break + } + } + } + + return coalesceToolOnlyAssistants(messages, createToolMergeCache()) +} + +// The same turn as the gateway persists it: one row per agent iteration. A row +// is a single API response, so its reasoning and content always precede its own +// tool_calls — anything the agent says after a tool ran belongs to the next row. +function replayStored(steps: TurnStep[]): ChatMessage[] { + const rows: SessionMessage[] = [] + let timestamp = 0 + let row: (SessionMessage & { tool_calls?: unknown[] }) | null = null + + const openRow = (afterTools: boolean) => { + if (row && !(afterTools && row.tool_calls)) { + return row + } + + timestamp += 1 + row = { role: 'assistant', content: '', timestamp } + rows.push(row) + + return row + } + + for (const step of steps) { + switch (step.kind) { + case 'interim': + + case 'final': + case 'text': { + const current = openRow(true) + + current.content = `${current.content ?? ''}${step.text}` + + if (step.kind !== 'text') { + row = null + } + + break + } + + case 'reasoning': + openRow(true).reasoning = step.text + + break + case 'tool': { + const current = openRow(false) + + current.tool_calls = [ + ...(current.tool_calls ?? []), + { id: step.id, function: { name: step.name, arguments: '{}' } } + ] + timestamp += 1 + rows.push({ role: 'tool', tool_call_id: step.id, tool_name: step.name, content: '{}', timestamp }) + + break + } + } + } + + return coalesceToolOnlyAssistants(toChatMessages(rows), createToolMergeCache()) +} + +/** + * Maximal spans of back-to-back tool calls — the same rule assistant-ui applies + * when it hands `ToolGroupSlot` a range, restated here so these tests check our + * two part streams against each other rather than against the renderer. + */ +function toolRuns(parts: ChatMessagePart[]): ToolCallLike[][] { + const runs: ToolCallLike[][] = [] + let previousWasTool = false + + for (const part of parts) { + if (!isToolCallPart(part)) { + previousWasTool = false + + continue + } + + if (previousWasTool) { + runs[runs.length - 1].push(part) + } else { + runs.push([part]) + } + + previousWasTool = true + } + + return runs +} + +function runsAcross(messages: ChatMessage[]): string[][] { + return messages + .filter(message => message.role === 'assistant') + .flatMap(message => toolRuns(message.parts)) + .map(run => run.map(tool => tool.toolCallId ?? '')) +} + +const TURNS: Record = { + 'narration between two tool runs': [ + { kind: 'interim', text: 'Let me check the config.' }, + { kind: 'tool', id: 'a', name: 'read_file' }, + { kind: 'tool', id: 'b', name: 'read_file' }, + { kind: 'interim', text: 'Now let me edit it.' }, + { kind: 'tool', id: 'c', name: 'write_file' }, + { kind: 'final', text: 'Done.' } + ], + 'reasoning between two tool runs': [ + { kind: 'tool', id: 'a', name: 'terminal' }, + { kind: 'reasoning', text: 'That failed, try the other path.' }, + { kind: 'tool', id: 'b', name: 'terminal' }, + { kind: 'final', text: 'Fixed.' } + ], + 'unbroken run of tool calls': [ + { kind: 'tool', id: 'a', name: 'read_file' }, + { kind: 'tool', id: 'b', name: 'read_file' }, + { kind: 'tool', id: 'c', name: 'search_files' }, + { kind: 'final', text: 'Here is what I found.' } + ], + 'reasoning then tools then final': [ + { kind: 'reasoning', text: 'The user wants the lint config.' }, + { kind: 'tool', id: 'a', name: 'search_files' }, + { kind: 'tool', id: 'b', name: 'read_file' }, + { kind: 'final', text: 'It lives in eslint.config.js.' } + ], + 'tools with no narration at all': [ + { kind: 'tool', id: 'a', name: 'terminal' }, + { kind: 'final', text: 'Clean.' } + ] +} + +describe('tool run segmentation survives rehydration', () => { + for (const [name, steps] of Object.entries(TURNS)) { + it(name, () => { + expect(runsAcross(replayLive(steps))).toEqual(runsAcross(replayStored(steps))) + }) + } +}) + +describe('run identity', () => { + const tool = (id: string, name: string): ChatMessagePart => + ({ args: {}, toolCallId: id, toolName: name, type: 'tool-call' }) as ChatMessagePart + + it('keeps the first tool call at the head as the run grows', () => { + const [run] = toolRuns([tool('a', 'read_file'), tool('b', 'read_file')]) + const [grown] = toolRuns([tool('a', 'read_file'), tool('b', 'read_file'), tool('c', 'terminal')]) + + expect(grown[0].toolCallId).toBe(run[0].toolCallId) + expect(grown).toHaveLength(3) + }) + + it('breaks a run on any non-tool part', () => { + const runs = toolRuns([tool('a', 'read_file'), assistantTextPart('Now editing.'), tool('b', 'write_file')]) + + expect(runs.map(run => run.map(t => t.toolCallId))).toEqual([['a'], ['b']]) + }) +}) diff --git a/apps/desktop/src/lib/use-enter-animation.test.tsx b/apps/desktop/src/lib/use-enter-animation.test.tsx new file mode 100644 index 000000000000..936aeab26687 --- /dev/null +++ b/apps/desktop/src/lib/use-enter-animation.test.tsx @@ -0,0 +1,82 @@ +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { useEnterAnimation } from './use-enter-animation' + +interface PlayedAnimation { + keyframes: Keyframe[] + options: KeyframeAnimationOptions +} + +/** + * Mounts one element through the hook and reports the animation it played, if + * any. jsdom has no Web Animations API, so `animate` is the seam. + */ +function mountAnimated(enabled: boolean, animationKey?: string): PlayedAnimation | undefined { + let played: PlayedAnimation | undefined + + function Probe() { + const ref = useEnterAnimation(enabled, animationKey) + + return
    + } + + // Defined rather than spied on: jsdom ships no Web Animations API at all, so + // there is no `animate` to wrap. + Object.defineProperty(HTMLElement.prototype, 'animate', { + configurable: true, + value: (keyframes: Keyframe[], options: KeyframeAnimationOptions) => { + played = { keyframes, options } + + return {} as Animation + }, + writable: true + }) + render() + + return played +} + +afterEach(() => { + cleanup() + Reflect.deleteProperty(HTMLElement.prototype, 'animate') +}) + +describe('useEnterAnimation', () => { + it('plays once on mount', () => { + const played = mountAnimated(true, 'plays-once') + + expect(played).toBeDefined() + expect(played?.keyframes[0]).toMatchObject({ opacity: 0 }) + }) + + it('stays out of the way when disabled', () => { + expect(mountAnimated(false, 'disabled')).toBeUndefined() + }) + + // A key is only banked once the node survives a microtask, so that a mount + // React immediately tears down doesn't burn it. + it('does not replay for a key that already animated', async () => { + expect(mountAnimated(true, 'replay')).toBeDefined() + await Promise.resolve() + + expect(mountAnimated(true, 'replay')).toBeUndefined() + }) + + /** + * The animation fills forwards, so any value in its last keyframe is held in + * the animation origin of the cascade for the life of the element — above + * the stylesheet. Naming an end opacity therefore doesn't just finish the + * fade, it permanently overrules whatever opacity CSS wants the element to + * rest at, and transcript scaffolding rests dimmed. Rows that animated in + * during the turn stayed bright while their rehydrated neighbours faded, and + * no hover could lift the bright ones because the sheet had lost the + * argument. Opacity has to be left to CSS at the end. + */ + it('leaves the resting opacity to the stylesheet', () => { + const played = mountAnimated(true, 'resting-opacity') + + expect(played?.options.fill).toBe('both') + expect(played?.keyframes.at(-1)).not.toHaveProperty('opacity') + }) +}) diff --git a/apps/desktop/src/lib/use-enter-animation.ts b/apps/desktop/src/lib/use-enter-animation.ts index 259cf7a388dc..267eaa4cac5c 100644 --- a/apps/desktop/src/lib/use-enter-animation.ts +++ b/apps/desktop/src/lib/use-enter-animation.ts @@ -82,7 +82,17 @@ export function useEnterAnimation(enabled: boolean, animationKey?: string): (el: el.animate( [ { opacity: 0, transform: 'translateY(0.375rem)' }, - { opacity: 1, transform: 'translateY(0)' } + // No `opacity` on the way out, deliberately. A filled animation holds + // its final value in the animation origin of the cascade, which + // outranks the stylesheet for as long as the element lives — naming 1 + // here permanently pinned full opacity onto everything the sheet dims. + // Transcript scaffolding is dimmed that way, so a tool row or thinking + // header kept whichever opacity it happened to mount with: full if it + // animated in during the turn, faded if it was rehydrated or remounted + // past its one-shot key. Adjacent identical rows disagreed, and hover + // couldn't lift the pinned ones. Left neutral, opacity rises to + // whatever CSS says it should be and answers hover afterwards. + { transform: 'translateY(0)' } ], { duration: 180, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'both' } ) diff --git a/apps/desktop/src/store/tool-drafting.ts b/apps/desktop/src/store/tool-drafting.ts new file mode 100644 index 000000000000..eae645142be7 --- /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 9fb935bbe088..541da35b6bc3 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. */ @@ -1413,15 +1416,18 @@ text-* variant utilities. */ .btn-arc { background: transparent !important; } -/* Fade scaffolding so the prose reading column stays primary. Two targets: - a thinking disclosure fades as one block, and each *individual* tool row - (`[data-tool-row]`) fades on its own. We deliberately do NOT fade the tool - group wrapper (`[data-tool-group]`): opacity on a parent opens a stacking - context, so a child row can never be more opaque than the group — that made - it impossible to keep one row lit (an open diff) while its siblings faded. - With the fade per-row, each row hovers/focuses independently. */ -[data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure'], -[data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-tool-row] { +/* Fade scaffolding so the prose reading column stays primary. Each surface + opts in with `data-conversation-scaffold` — thinking header, tool row, run + summary, live status line — instead of being named by its own selector here. + Spelling them out individually is how the status line came to sit a shade + brighter than the rows either side of it. + + Marked per surface and never on a container: opacity opens a stacking + context, so nothing inside a faded parent can be more opaque than it, and + one row (an open diff) could not stay lit while its siblings dimmed. Hence + the tool group wrapper carries no mark and each row inside it carries its + own — no two marked elements nest, so the fade never compounds. */ +[data-slot='aui_assistant-message-content'] [data-conversation-scaffold] { opacity: 0.67; transition: opacity 120ms ease-out; } @@ -1429,8 +1435,7 @@ text-* variant utilities. */ .btn-arc { /* Lift on hover or *keyboard* focus only. `:focus-within` also matches the focus a mouse click leaves on the disclosure toggle, which kept a row lit after you clicked to collapse it; `:has(:focus-visible)` excludes that. */ -[data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure']:is(:hover, :has(:focus-visible)), -[data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-tool-row]:is(:hover, :has(:focus-visible)) { +[data-slot='aui_assistant-message-content'] [data-conversation-scaffold]:is(:hover, :has(:focus-visible)) { opacity: 1; } @@ -1496,24 +1501,33 @@ text-* variant utilities. */ .btn-arc { /* Conversation block rhythm. assistant-ui renders each range as a direct child of the message content with no per-part wrapper, so adjacency rules cover every pairing — first block needs no reset, nested tool rows are untouched. - Two tiers: scaffolding (tool / thinking) gets a roomy block gap so it reads - as separate from the reply; consecutive prose collapses to a tight paragraph - rhythm so split-out text parts don't look like a big gap. */ -/* Scaffolding adjacent to anything → roomy block gap. */ + + One gap between top-level blocks, whatever they are. Spelling out which + *pairs* qualified meant the live status line (not tool, thinking or prose) + fell through every branch and carried its own half-size margin instead. */ [data-slot='aui_assistant-message-content'] - > :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) - + :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], .aui-md), -[data-slot='aui_assistant-message-content'] - > .aui-md - + :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) { + > :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall'], .aui-md) + + :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall'], .aui-md) { margin-top: var(--turn-block-gap); } -/* Prose ↔ prose → tight paragraph rhythm, matching in-block paragraph spacing. */ +/* Except prose ↔ prose: one reading column that happens to arrive as several + parts, so it keeps the in-block paragraph rhythm. Same specificity as the + rule above and deliberately after it. */ [data-slot='aui_assistant-message-content'] > .aui-md + .aui-md { margin-top: var(--paragraph-gap); } +/* A streaming turn is sealed into several assistant bubbles as it goes + (`message.interim`) and rehydrates into fewer, so the same two blocks are + sometimes siblings inside one bubble and sometimes split across two. The + flex gap between bubbles is the turn gap — half the block gap — which made + the rhythm visibly tighten and then relax as a turn settled. Top it up so + the column ticks at one interval either way. */ +[data-slot='aui_turn-pair'] > [data-slot='aui_assistant-message-root'] + [data-slot='aui_assistant-message-root'] { + margin-top: calc(var(--turn-block-gap) - var(--conversation-turn-gap)); +} + /* Message action bars — flat icon hits with default dim; only the hovered/focused control is full-strength. */ [data-slot='aui_msg-actions'] button { border: 0; @@ -1565,32 +1579,33 @@ text-* variant utilities. */ .btn-arc { --thread-last-message-clearance: calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 2rem); } -/* Long adjacent tool-call run collapsed into a fixed, auto-scrolling window. - ToolGroupSlot pins the newest call to the bottom (unless the user scrolls - up), so a back-to-back run stays compact instead of pushing the reply off - screen. A thin scrollbar keeps the affordance discoverable. */ -.tool-group-scroll { - scrollbar-width: thin; - scrollbar-color: var(--ui-stroke-tertiary) transparent; +/* A live tool run, shown as one line. `ToolRunTicker` stacks the run's rows + into a reel and offsets it by the newest row's index, so each new action + slides the one before it up and out of a single-line window — the run reads + as one line ticking over in place rather than a list growing down the page. + Rows are clipped to a uniform line box so the offset stays exact whatever a + row contains. */ +.tool-ticker { + height: var(--conversation-line-height); + overflow: hidden; } -/* Break out of the fixed window the moment any row inside is expanded: the - user is now reading a diff/output, so let it grow to full height instead of - peering at it through a ~2-row viewport. Collapsing the row drops it back - into the compact, auto-scrolling window. Beats a larger fixed cap since an - open row already bounds its own payload with an inner scroll. */ -.tool-group-scroll:has([data-tool-row][data-tool-open]) { - max-height: none; - -webkit-mask-image: none; - mask-image: none; +.tool-ticker__reel { + transform: translateY(calc(var(--tool-ticker-index, 0) * var(--conversation-line-height) * -1)); + transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1); } -/* Top gradient — only applied once the user is scrolled down off the top, so - the oldest visible call fades up under the fade while the first row stays - fully legible when scrolled all the way up. */ -.tool-group-scroll--faded { - -webkit-mask-image: linear-gradient(to bottom, transparent 0, black 2rem, black 100%); - mask-image: linear-gradient(to bottom, transparent 0, black 2rem, black 100%); +.tool-ticker__row { + display: flex; + height: var(--conversation-line-height); + align-items: center; + overflow: hidden; +} + +@media (prefers-reduced-motion: reduce) { + .tool-ticker__reel { + transition: none; + } } @keyframes code-card-stream-enter {