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 1502aa82a91..508ead3ec9d 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx @@ -9,7 +9,7 @@ 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 { formatElapsed, useElapsedSeconds } from '@/components/chat/activity-timer' +import { formatElapsed, useElapsedSeconds, useMeasuredDuration } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { GeneratedImage } from '@/components/chat/generated-image-result' import { SCAFFOLD_LABEL_CLASS, SCAFFOLD_META_CLASS, ScaffoldRow } from '@/components/chat/scaffold-row' @@ -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,28 +76,11 @@ const ThinkingDisclosure: FC<{ const open = userOpen ?? pending const isPreview = pending && userOpen === null - // How long the model thought is only knowable by having watched it happen — - // nothing in the persisted turn records it. So freeze the number the moment - // this block finishes in front of us, and stay quiet on a rehydrated turn - // rather than reporting whatever a timer that never ran would say. - const [watching, setWatching] = useState(false) - const [thoughtFor, setThoughtFor] = useState(null) - - useEffect(() => { - if (pending) { - setWatching(true) - } else if (watching) { - setWatching(false) - setThoughtFor(elapsed) - } - }, [elapsed, pending, watching]) - // Three ways a finished block can report itself. With a measured duration it // says so, unless the timer's whole seconds round it to "0s" — accurate and // useless — in which case it just says it was quick. With no duration at all - // (rehydrated history, or reasoning that arrived already complete so we never - // saw it run) it still has to read as finished; a turn that ended must not go - // on saying "Thinking". + // 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) { @@ -213,7 +199,15 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star } return ( - + // 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/streaming.test.tsx b/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx index c9d5dc53bae..0cedda1e7b1 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/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index 70d6d050331..16e5f9064af 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -30,7 +30,6 @@ import { ZoomableImage } from '@/components/chat/zoomable-image' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { CopyButton } from '@/components/ui/copy-button' -import { DiffCount } from '@/components/ui/diff-count' import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { FadeText } from '@/components/ui/fade-text' import { FileTypeIcon } from '@/components/ui/file-type-icon' @@ -68,7 +67,7 @@ import { type ToolStatus, type ToolTitleAction } from './fallback-model' -import { isToolCallPart, type RunSummary, summarizeToolRun } from './run-summary' +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 @@ -784,10 +783,9 @@ function ToolRunTicker({ children }: { children: ReactNode }) { ) } -// The one grey line that stands in for a run of tool calls once it has -// settled — "Edited wiring.tsx, explored 3 files +6 −4". While the run is -// live the same line narrates it in the present tense and the rows stay -// visible below, so nothing the user is waiting on hides behind a chevron. +// 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, @@ -797,15 +795,14 @@ function ToolRunHeader({ live: boolean onToggle?: () => void open: boolean - summary: RunSummary + summary: string }) { return (
- {live ? {summary.text} : summary.text} + {live ? {summary} : summary} -
) @@ -817,7 +814,7 @@ interface ToolRunState { live: boolean /** A call still awaiting a result that could be the one blocking on approval. */ pendingApprovalTool: boolean - summary: RunSummary + summary: string } // assistant-ui compares selector results with `Object.is` and calls the @@ -830,6 +827,7 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState { return useAuiState(state => { const parts = state.message.parts const tools = parts.slice(Math.max(0, startIndex), endIndex + 1).filter(isToolCallPart) + // A missing result only means "still working" while this run is the tail of // a running message — the same qualification ToolEntry puts on a row's // pending state. A turn that ends, or an agent that moves on to later @@ -838,6 +836,7 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState { // live run deliberately withholds its toggle. const live = selectMessageRunning(state) && endIndex >= parts.length - 1 && tools.some(tool => tool.result === undefined) + const signature = tools .map(tool => `${tool.toolCallId}:${tool.result === undefined ? 0 : 1}`) .concat(String(live)) 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 index be298640ce1..b2d4e5ac90b 100644 --- a/apps/desktop/src/components/assistant-ui/tool/run-summary.test.ts +++ b/apps/desktop/src/components/assistant-ui/tool/run-summary.test.ts @@ -6,61 +6,48 @@ function tool(toolName: string, args: Record = {}, result?: unk return { args, result, toolCallId: `${toolName}-${Math.random()}`, toolName } } -const edited = (path: string, diff = '') => tool('write_file', { path }, { path, inline_diff: diff }) 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 edit and counts the rest', () => { - expect(settled([edited('src/use-preview-routing.ts'), read('a.ts'), read('b.ts'), read('c.ts')]).text).toBe( - 'Edited use-preview-routing.ts, explored 3 files' + 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('orders clauses edit, explore, run regardless of call order', () => { - expect( - settled([ran('ls'), read('a.ts'), edited('src/attachments.tsx'), read('b.ts'), ran('pwd'), ran('id')]).text - ).toBe('Edited attachments.tsx, explored 2 files, ran 3 commands') - }) - it('counts commands rather than naming them once they have run', () => { - expect(settled([ran('git status')]).text).toBe('Ran 1 command') - expect(settled([read('status.ts'), ran('a'), ran('b'), ran('c'), ran('d'), ran('e')]).text).toBe( + 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('counts a multi-file edit', () => { - expect(settled([edited('a.tsx'), edited('b.tsx'), read('c.ts')]).text).toBe('Edited 2 files, explored c.ts') - }) - it('puts the running category in the present tense and leaves the rest past', () => { - expect(running([edited('a.tsx'), tool('write_file', { path: 'b.tsx' }), ran('x'), ran('y')]).text).toBe( - 'Editing 2 files, ran 2 commands' + 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' })]).text).toMatch(/^Running /) + expect(running([tool('terminal', { command: 'npm run typecheck' })])).toMatch(/^Running /) }) // 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' })]).text).toBe('Explored 2 files') - }) - - it('sums diff stats across the edits in the run', () => { - const summary = settled([edited('a.tsx', '--- a\n+++ b\n+one\n+two\n-old'), edited('b.tsx', '+three'), ran('ls')]) - - expect(summary).toMatchObject({ added: 3, removed: 1 }) - }) - - it('reports no diff stats for a run that changed nothing', () => { - expect(settled([read('a.ts'), ran('ls')])).toMatchObject({ added: 0, removed: 0 }) + 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 index a23970be4d9..7d7974ad10b 100644 --- a/apps/desktop/src/components/assistant-ui/tool/run-summary.ts +++ b/apps/desktop/src/components/assistant-ui/tool/run-summary.ts @@ -1,13 +1,6 @@ import { summarizeShellCommand } from '@/lib/summarize-command' -import { - countDiffLineStats, - fileEditBasename, - firstStringField, - inlineDiffFromResult, - isFileEditTool, - parseMaybeObject -} from './fallback-model' +import { fileEditBasename, firstStringField, isFileEditTool, parseMaybeObject } from './fallback-model' /** * The little a summary needs from a tool call, stated structurally so both @@ -27,12 +20,6 @@ export function isToolCallPart(part: T): part is Ext type RunCategory = 'delegate' | 'edit' | 'explore' | 'other' | 'run' -export interface RunSummary { - added: number - removed: number - text: string -} - // 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'] @@ -101,24 +88,6 @@ function toolTarget(tool: ToolCallLike): string { return path ? fileEditBasename(path) : firstStringField(args, ['query', 'url']) } -function diffStats(tools: readonly ToolCallLike[]): { added: number; removed: number } { - let added = 0 - let removed = 0 - - for (const tool of tools) { - if (!isFileEditTool(tool.toolName)) { - continue - } - - const stats = countDiffLineStats(inlineDiffFromResult(tool.result)) - - added += stats.added - removed += stats.removed - } - - return { added, removed } -} - /** * 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 @@ -143,16 +112,20 @@ function lowerFirst(text: string): string { /** * Collapse a run of tool calls into the single grey line that stands in for it - * — "Edited wiring.tsx, explored 3 files, ran 5 commands". The category holding - * the still-running tool speaks in the present tense so a live run reads as - * work in progress rather than work already done. + * — "Explored 3 files, ran 5 commands". The category holding the still-running + * tool speaks in the present tense so a live run 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): RunSummary { +export function summarizeToolRun(tools: readonly ToolCallLike[], live: boolean): string { const running = live ? tools.find(isPending) : undefined const liveCategory = running ? toolCategory(running.toolName) : null @@ -175,8 +148,5 @@ export function summarizeToolRun(tools: readonly ToolCallLike[], live: boolean): return group ? [clause(category, group, category === liveCategory)] : [] }) - return { - ...diffStats(tools), - text: clauses.map((text, index) => (index === 0 ? text : lowerFirst(text))).join(', ') - } + 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 index 75595a248fe..e94917226de 100644 --- a/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx @@ -184,8 +184,8 @@ function failedOnlyMessage(): ThreadMessage { } as ThreadMessage } -// Two settled tools in a row — an edit plus a read — so the run earns a -// summary line and collapses. +// 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', @@ -193,7 +193,60 @@ function settledRunMessage(): ThreadMessage { content: [ { type: 'tool-call', - toolCallId: 'patch-1', + 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' }), @@ -201,11 +254,19 @@ function settledRunMessage(): ThreadMessage { }, { type: 'tool-call', - toolCallId: 'read-2', + toolCallId: 'read-6', toolName: 'read_file', - args: { path: '/repo/src/status.tsx' }, - argsText: JSON.stringify({ path: '/repo/src/status.tsx' }), - result: { content: 'export const Status = () => null' } + 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' }, @@ -326,18 +387,17 @@ afterEach(() => { }) describe('settled tool run', () => { - it('collapses to a summary line naming the work and its diff', async () => { + it('collapses to a summary line naming the work', async () => { const { container } = render() - expect(await screen.findByText('Edited wiring.tsx, explored status.tsx')).toBeTruthy() + expect(await screen.findByText('Explored wiring.tsx, ran 1 command')).toBeTruthy() expect(container.querySelectorAll('[data-tool-row]')).toHaveLength(0) - expect(screen.getByText('1', { selector: '.text-\\(--ui-green\\) *' })).toBeTruthy() }) it('expands to the underlying rows when the summary is clicked', async () => { const { container } = render() - fireEvent.click(await screen.findByText('Edited wiring.tsx, explored status.tsx')) + fireEvent.click(await screen.findByText('Explored wiring.tsx, ran 1 command')) await waitFor(() => { expect(container.querySelectorAll('[data-tool-row]').length).toBeGreaterThan(0) @@ -354,6 +414,31 @@ describe('settled tool run', () => { }) }) +// 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() + }) + }) +}) + describe('live tool run', () => { it('keeps its rows on screen instead of hiding them behind the summary', async () => { const { container } = render() @@ -425,7 +510,7 @@ describe('flat tool list approval surfacing', () => { const dismiss = await screen.findByLabelText('Dismiss') - expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(1) + expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0) fireEvent.click(dismiss) @@ -449,13 +534,13 @@ describe('flat tool list approval surfacing', () => { first.unmount() - const { container } = render() + render() + // The row is the only thing this message renders, so staying dismissed + // means nothing comes back — including its dismiss control. await waitFor(() => { - expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0) + expect(screen.queryByLabelText('Dismiss')).toBeNull() }) - - expect(screen.queryByLabelText('Dismiss')).toBeNull() }) it('lets failed tool rows be dismissed', async () => { diff --git a/apps/desktop/src/components/chat/activity-timer.test.tsx b/apps/desktop/src/components/chat/activity-timer.test.tsx index 4768f60c56e..02be8798568 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 9fe67642239..6eddba167fb 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() }