diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index e373d9ed886..f5a46b5e8f7 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react"; +import { useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react"; import { IssueChatThread } from "@/components/IssueChatThread"; import { useLiveRunTranscripts, type RunTranscriptSource } from "@/components/transcript/useLiveRunTranscripts"; import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter"; @@ -154,15 +154,6 @@ export function TaskChatThread(props: TaskChatThreadProps) { return (liveRuns ?? []).find((r) => !isTerminalRunStatus(r.status)) ?? null; }, [activeRun, liveRuns]); - // Re-render on a 1s cadence while live so the status pill's elapsed advances - // even between transcript chunks. - const [tick, setTick] = useState(0); - useEffect(() => { - if (!liveRun) return; - const id = window.setInterval(() => setTick((t) => t + 1), 1000); - return () => window.clearInterval(id); - }, [liveRun]); - // Runs observed non-terminal while mounted: their turns ANIMATE the fold when // they settle. Runs already terminal at mount collapse instantly. const liveSeenRef = useRef>(new Set()); @@ -317,7 +308,6 @@ export function TaskChatThread(props: TaskChatThreadProps) { }); } const startedAt = liveRun.startedAt ? new Date(liveRun.startedAt).getTime() : null; - const elapsedMs = startedAt != null ? Math.max(0, Date.now() - startedAt) : undefined; const queued = liveRun.status === "queued"; const status = queued ? { label: "Queued", detail: "Waiting to start" } @@ -328,13 +318,11 @@ export function TaskChatThread(props: TaskChatThreadProps) { status: "running", label: status.label, detail: status.detail, - elapsedMs, + startedAtMs: startedAt ?? undefined, }); } return out; - // `tick` is intentionally a dependency so elapsed advances each second. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, firstCommentIdByRun, tick]); + }, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, firstCommentIdByRun]); const renderInteraction = useCallback( (item: TaskChatInteractionItem) => ( diff --git a/ui/src/components/task-chat/TaskChatStatusPill.tsx b/ui/src/components/task-chat/TaskChatStatusPill.tsx index cb5c6a20a0e..8cb3d8a0818 100644 --- a/ui/src/components/task-chat/TaskChatStatusPill.tsx +++ b/ui/src/components/task-chat/TaskChatStatusPill.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { cn } from "@/lib/utils"; import { Loader2, ShieldQuestion, OctagonX, Ban, Scissors } from "lucide-react"; import type { TaskChatStatusItem } from "./task-chat-model"; @@ -9,6 +10,25 @@ function elapsedLabel(ms?: number): string | null { return `${Math.floor(s / 60)}m ${s % 60}s`; } +/** Tenths-precision elapsed ("24.3s", "1m 24.3s") so the readout visibly moves. */ +function liveElapsedLabel(ms?: number): string | null { + if (ms == null) return null; + const s = Math.max(0, ms) / 1000; + if (s < 60) return `${s.toFixed(1)}s`; + return `${Math.floor(s / 60)}m ${(s % 60).toFixed(1)}s`; +} + +/** Elapsed ms since `startedAtMs`, ticking every 100ms while `live`. */ +function useLiveElapsedMs(startedAtMs: number | undefined, live: boolean): number | undefined { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (startedAtMs == null || !live) return; + const id = window.setInterval(() => setNow(Date.now()), 100); + return () => window.clearInterval(id); + }, [startedAtMs, live]); + return startedAtMs == null ? undefined : Math.max(0, now - startedAtMs); +} + const CONFIG = { running: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" }, working: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" }, @@ -33,9 +53,12 @@ interface TaskChatStatusPillProps { export function TaskChatStatusPill({ item, onApprovalDecision }: TaskChatStatusPillProps) { const { Icon, spin, tone } = CONFIG[item.status]; const awaiting = item.status === "awaiting_approval"; + const live = item.status === "running" || item.status === "working"; + const liveElapsedMs = useLiveElapsedMs(item.startedAtMs, live); const elapsed = elapsedLabel(item.elapsedMs); - if (item.status === "running" || item.status === "working") { + if (live) { + const liveElapsed = liveElapsedLabel(liveElapsedMs ?? item.elapsedMs); return (
{item.label}… + {liveElapsed ? ( + + {liveElapsed} + + ) : null} {item.detail ? ( {item.detail} ) : null} - - {elapsed ? {elapsed} : null} - {item.tokens ? ( - - {item.tokens.used.toLocaleString()}/{item.tokens.size.toLocaleString()} ctx - - ) : null} - + {item.tokens ? ( + + {item.tokens.used.toLocaleString()}/{item.tokens.size.toLocaleString()} ctx + + ) : null}
); } diff --git a/ui/src/components/task-chat/task-chat-fixtures.ts b/ui/src/components/task-chat/task-chat-fixtures.ts index ba5e08f6ec2..8dafe2c6ef8 100644 --- a/ui/src/components/task-chat/task-chat-fixtures.ts +++ b/ui/src/components/task-chat/task-chat-fixtures.ts @@ -99,14 +99,14 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario { return { surface: "thread", items: [ - { id: "st-working", kind: "status", status: "working", label: "Using edit", detail: "server/src/routes/auth.ts", elapsedMs: 4200 }, + { id: "st-working", kind: "status", status: "working", label: "Using edit", detail: "server/src/routes/auth.ts", startedAtMs: Date.now() - 4200 }, ], }; case "running": return { surface: "thread", items: [ - { id: "st-running", kind: "status", status: "running", label: "Running", detail: "no output for 3s — still running", elapsedMs: 12000, tokens: { used: 18240, size: 200000 } }, + { id: "st-running", kind: "status", status: "running", label: "Running", detail: "no output for 3s — still running", startedAtMs: Date.now() - 12000, tokens: { used: 18240, size: 200000 } }, ], }; case "completed": diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index f8c7909fe55..78c4f7170e6 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -127,6 +127,8 @@ export interface TaskChatStatusItem { label: string; detail?: string; elapsedMs?: number; + /** Run start epoch ms; live states tick their own elapsed from this. */ + startedAtMs?: number; tokens?: TaskChatTokenUsage; /** Present for awaiting_approval (ACP RequestPermissionRequest). */ approval?: { toolName: string; options: TaskChatApprovalOption[] };