import type { ExecutionProjection } from "@paperclipai/shared"; import { Loader2 } from "lucide-react"; import type { TranscriptEntry } from "../../adapters"; import { cn } from "@/lib/utils"; import { useSecondTick } from "@/hooks/useSecondTick"; import { formatDurationWords } from "@/lib/issue-chat-messages"; import { isCommandTool } from "@/lib/transcriptPresentation"; import { isTerminalRunStatus } from "@/components/task-chat/transcript-adapter"; import { useTaskChatShimmer } from "./TaskChatShimmer"; /** * "ran N commands, called M tools" for the live tail's status pill, counted off * the streamed transcript entries. Mirrors IssueChatThread's `toolCountSummary` * (which counts a settled message's tool-call parts) so the experimental live * tail reads the same as the default view. Streaming runtimes re-emit a * tool_call as its status progresses, so calls carrying a `toolUseId` are * counted once. */ export function toolCountSummaryFromEntries(entries: readonly TranscriptEntry[]): string | null { const seen = new Set(); let commands = 0; let other = 0; for (const entry of entries) { if (entry.kind !== "tool_call") continue; if (entry.toolUseId) { if (seen.has(entry.toolUseId)) continue; seen.add(entry.toolUseId); } if (isCommandTool(entry.name, entry.input)) commands += 1; else other += 1; } const parts: string[] = []; if (commands > 0) parts.push(`ran ${commands} command${commands === 1 ? "" : "s"}`); if (other > 0) parts.push(`called ${other} tool${other === 1 ? "" : "s"}`); return parts.length > 0 ? parts.join(", ") : null; } /** * Status pill for the experimental chat-style view's live tail (PAP-461, A1). * * Byte-for-byte the same affordance the default view shows above a run's chain * of thought (IssueChatThread's CoT header): a spinner + shimmering "Working" * verb + elapsed words + "· called N tools" while the run streams, settling to * a static emerald dot + "Worked" summary once it terminates. The verb shimmers * via `.shimmer-text` (reduced-motion aware) exactly as the default view does. */ export function TaskChatLiveRunPill({ status, startedAtMs, finishedAtMs, toolSummary, }: { status: string; execution?: ExecutionProjection | null; /** Run start (startedAt, falling back to createdAt) in ms, or null if unknown. */ startedAtMs: number | null; /** Run finish in ms once terminal; drives the settled elapsed readout. */ finishedAtMs?: number | null; toolSummary: string | null; }) { const active = !isTerminalRunStatus(status); const shimmer = useTaskChatShimmer("working"); // One shared page-wide ticker drives the live elapsed readout, matching the // default view's `useLiveElapsed`. useSecondTick(active && startedAtMs != null); const elapsedMs = startedAtMs == null ? null : (active ? Date.now() : finishedAtMs ?? Date.now()) - startedAtMs; const elapsed = elapsedMs != null ? formatDurationWords(elapsedMs) : null; const failed = ["failed", "timed_out", "cancelled", "interrupted"].includes(status); const verb = active ? "Working" : failed ? "Stopped" : "Worked"; const suffix = elapsed ? `for ${elapsed}` : null; return (
{active ? ( ) : ( )} {active ? {verb} : verb} {suffix ? {suffix} : null} {toolSummary ? · {toolSummary} : null}
); }