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 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Fable 5 committed 2026-08-07 16:55:49 -07:00
1 parent 0851608168
commit 67e0713a2a
4 files changed
+41 -26

No files matched your search

+3 -15
View File
@@ -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<Set<string>>(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) => (
@@ -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 (
<div className="tc-enter-status flex items-center gap-2 py-0.5 text-xs text-muted-foreground">
<span
@@ -43,17 +66,19 @@ export function TaskChatStatusPill({ item, onApprovalDecision }: TaskChatStatusP
className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-(--status-agent-running)"
/>
<span className="shimmer-text shimmer-text-muted font-medium">{item.label}…</span>
{liveElapsed ? (
<span className="shrink-0 font-mono tabular-nums text-(length:--text-micro)">
{liveElapsed}
</span>
) : null}
{item.detail ? (
<span className="min-w-0 truncate font-mono text-(length:--text-micro)">{item.detail}</span>
) : null}
<span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-(length:--text-micro)">
{elapsed ? <span>{elapsed}</span> : null}
{item.tokens ? (
<span>
{item.tokens.used.toLocaleString()}/{item.tokens.size.toLocaleString()} ctx
</span>
) : null}
</span>
{item.tokens ? (
<span className="ml-auto shrink-0 font-mono text-(length:--text-micro)">
{item.tokens.used.toLocaleString()}/{item.tokens.size.toLocaleString()} ctx
</span>
) : null}
</div>
);
}
@@ -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":
@@ -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[] };