From 67e0713a2a03529b532253fe6642f4c2ef1b6d82 Mon Sep 17 00:00:00 2001 From: scotttong Date: Fri, 31 Jul 2026 17:23:28 -0700 Subject: [PATCH] feat(ui): task-chat live status elapsed sits beside the label and ticks in tenths The running/working status line now shows elapsed time directly to the right of the label (was pushed to the far right edge) and renders it with one decimal (24.3s) so the readout visibly moves. The pill ticks itself every 100ms from the run's start timestamp (new startedAtMs on TaskChatStatusItem), replacing the 1s whole-thread re-render tick in TaskChatThread. Co-Authored-By: Claude Fable 5 --- ui/src/components/TaskChatThread.tsx | 18 ++------ .../task-chat/TaskChatStatusPill.tsx | 43 +++++++++++++++---- .../task-chat/task-chat-fixtures.ts | 4 +- .../components/task-chat/task-chat-model.ts | 2 + 4 files changed, 41 insertions(+), 26 deletions(-) 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[] };