diff --git a/apps/desktop/src/components/assistant-ui/thread/status.tsx b/apps/desktop/src/components/assistant-ui/thread/status.tsx index b239f0fb8a8..5b9a5837c26 100644 --- a/apps/desktop/src/components/assistant-ui/thread/status.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/status.tsx @@ -142,7 +142,11 @@ export const StreamStallIndicator: FC = () => { return `${s.message.content.length}:${textLength}` }) - const [stalled, setStalled] = useState(false) + // Timestamp of the activity that preceded the current quiet spell, set once + // the spell qualifies as a stall. Holding the timestamp (not a boolean) is + // 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 compacting = useStore($compactionActive) const turnTimerKey = useActiveTurnTimerKey() // A pending clarify / approval / sudo / secret means the turn is paused on the @@ -151,14 +155,18 @@ export const StreamStallIndicator: FC = () => { const awaitingInput = useStore($activeSessionAwaitingInput) useEffect(() => { - setStalled(false) - const id = window.setTimeout(() => setStalled(true), STREAM_STALL_S * 1000) + setQuietSince(undefined) + const seenAt = Date.now() + const id = window.setTimeout(() => setQuietSince(seenAt), STREAM_STALL_S * 1000) return () => window.clearTimeout(id) }, [activity]) - const active = (stalled || compacting) && !awaitingInput - const elapsed = useElapsedSeconds(active, compacting ? turnTimerKey : undefined) + const active = (quietSince !== undefined || compacting) && !awaitingInput + + // Compaction owns the whole turn, so it keeps counting from the turn's start; + // a plain stall counts from the last thing the stream produced. + const elapsed = useElapsedSeconds(active, compacting ? turnTimerKey : undefined, compacting ? undefined : quietSince) if (!active) { return null diff --git a/apps/desktop/src/components/chat/activity-timer.test.tsx b/apps/desktop/src/components/chat/activity-timer.test.tsx index acc70a99ed0..4768f60c56e 100644 --- a/apps/desktop/src/components/chat/activity-timer.test.tsx +++ b/apps/desktop/src/components/chat/activity-timer.test.tsx @@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { __resetElapsedTimerRegistryForTests, useElapsedSeconds } from './activity-timer' -function Probe({ active, timerKey }: { active: boolean; timerKey?: string }) { - const elapsed = useElapsedSeconds(active, timerKey) +function Probe({ active, since, timerKey }: { active: boolean; since?: number; timerKey?: string }) { + const elapsed = useElapsedSeconds(active, timerKey, since) return {elapsed} } @@ -40,4 +40,30 @@ describe('useElapsedSeconds', () => { expect(screen.getByTestId('elapsed').textContent).toBe('8') }) + + it('counts from an explicit epoch rather than mount time', () => { + const mountedAt = Date.now() + + act(() => { + vi.advanceTimersByTime(30_000) + }) + + render() + + expect(screen.getByTestId('elapsed').textContent).toBe('2') + }) + + it('re-anchors when the epoch moves', () => { + const { rerender } = render() + + act(() => { + vi.advanceTimersByTime(10_000) + }) + + expect(screen.getByTestId('elapsed').textContent).toBe('10') + + rerender() + + expect(screen.getByTestId('elapsed').textContent).toBe('0') + }) }) diff --git a/apps/desktop/src/components/chat/activity-timer.ts b/apps/desktop/src/components/chat/activity-timer.ts index afb27fb02f3..9fe67642239 100644 --- a/apps/desktop/src/components/chat/activity-timer.ts +++ b/apps/desktop/src/components/chat/activity-timer.ts @@ -30,13 +30,22 @@ export function formatElapsed(seconds: number): string { return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}` } -export function useElapsedSeconds(active = true, timerKey?: string): number { - const start = useRef(startedAt(timerKey)) +/** + * Seconds since the timer's origin, reported once a second while `active`. + * + * Origin, in order: an explicit `since` timestamp, else the `timerKey`'s + * registry entry (survives unmount/remount), else mount time. Pass `since` when + * the thing being measured started at a moment the caller knows and that moment + * isn't the mount — otherwise an anonymous timer reports the component's age, + * which is only the same number by accident. + */ +export function useElapsedSeconds(active = true, timerKey?: string, since?: number): number { + const start = useRef(since ?? startedAt(timerKey)) const lastKey = useRef(timerKey) const [elapsed, setElapsed] = useState(() => Math.max(0, Math.floor((Date.now() - start.current) / 1000))) if (lastKey.current !== timerKey) { - start.current = startedAt(timerKey) + start.current = since ?? startedAt(timerKey) lastKey.current = timerKey } @@ -46,7 +55,9 @@ export function useElapsedSeconds(active = true, timerKey?: string): number { return } - if (timerKey) { + if (since !== undefined) { + start.current = since + } else if (timerKey) { start.current = startedAt(timerKey) } @@ -55,7 +66,7 @@ export function useElapsedSeconds(active = true, timerKey?: string): number { const id = window.setInterval(tick, 1000) return () => window.clearInterval(id) - }, [active, timerKey]) + }, [active, since, timerKey]) return elapsed }