/** * @file WorkflowRunsPanel.tsx * @description Surfaces dynamic Workflow-tool runs (issue #167) - fleets of * inner sub-agents spawned by the Claude Code "Workflow" tool, ingested from * on-disk run journals. Works in two modes: controlled (pass `runs`, e.g. from * SessionDetail) or self-fetching (pass a `statusFilter`, e.g. the Workflows * page) with live `workflow_upserted` updates. Each run expands to colored, * clickable phase filters, a per-agent metrics table, and an expandable list of * per-agent results. The collapsed row shows a short teaser from the run * journal; expanding an agent lazily fetches its full transcript (the journal * only carries server-truncated previews) and renders the complete prompt and * result, falling back to the teaser when the transcript is pruned/unavailable. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Observability * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and * Docker Compose profiles are documented in `monitoring/README.md`. * * ## Internal dependencies * - `../../lib/api` * - `../../lib/eventBus` * - `../../lib/types` * - `../../lib/format` * * ## Public surface * - `friendlyPreview` — exported API; see TSDoc on the symbol for behavior. * - `fullPreview` — exported API; see TSDoc on the symbol for behavior. * - `extractPromptResult` — exported API; see TSDoc on the symbol for behavior. * - `WorkflowRunsPanel` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `.claude/skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **friendlyPreview** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * **fullPreview** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * **extractPromptResult** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * **WorkflowRunsPanel** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { useCallback, useEffect, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Workflow, ChevronRight, ChevronDown, Layers, ExternalLink, Loader2 } from "lucide-react"; import { api } from "../../lib/api"; import { eventBus } from "../../lib/eventBus"; import type { WorkflowRun, WorkflowProgressEntry, WSMessage, TranscriptMessage, } from "../../lib/types"; import { fmt, formatMs, timeAgo, truncate } from "../../lib/format"; type StatusFilter = "all" | "active" | "completed"; interface Props { /** Controlled mode: render exactly these runs (no fetch, no live updates). */ runs?: WorkflowRun[]; /** Self-fetch mode: page-level status filter (active → running). */ statusFilter?: StatusFilter; /** Self-fetch mode: scope to one session. */ sessionId?: string; /** Hide the parent-session link (e.g. when already on that session). */ hideSessionLink?: boolean; } const STATUS_STYLES: Record = { running: "bg-amber-500/15 text-amber-400 border-amber-500/30", working: "bg-amber-500/15 text-amber-400 border-amber-500/30", queued: "bg-gray-500/15 text-gray-400 border-gray-500/30", completed: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", done: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", success: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", error: "bg-red-500/15 text-red-400 border-red-500/30", failed: "bg-red-500/15 text-red-400 border-red-500/30", }; function statusClass(status: string): string { return STATUS_STYLES[status] || "bg-gray-500/15 text-gray-400 border-gray-500/30"; } // Distinct per-phase chip colors, cycled by phase index so every phase // (e.g. Scout / Verify / Synthesize, or Explain / Interview / Gotcha) reads // as its own color in both the filter row and the result label chips. const PHASE_PALETTE = [ "bg-violet-500/15 text-violet-300 border-violet-500/40", "bg-sky-500/15 text-sky-300 border-sky-500/40", "bg-amber-500/15 text-amber-300 border-amber-500/40", "bg-emerald-500/15 text-emerald-300 border-emerald-500/40", "bg-rose-500/15 text-rose-300 border-rose-500/40", "bg-cyan-500/15 text-cyan-300 border-cyan-500/40", "bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-500/40", ]; function phaseColor(phaseTitles: string[], title: string | null | undefined): string { if (!title) return "bg-gray-500/15 text-gray-300 border-gray-500/40"; const i = phaseTitles.indexOf(title); const idx = i >= 0 ? i : Math.abs(hashStr(title)) % PHASE_PALETTE.length; return PHASE_PALETTE[idx % PHASE_PALETTE.length] as string; } function hashStr(s: string): number { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return h; } /** * Surface a human-readable excerpt from an agent's result preview, which is * often a (frequently truncated) JSON blob. Prefer a known content field, then * the first substantial quoted string, then a de-JSON'd snippet - so the panel * shows a sentence instead of raw `{"angle":"…","findings":[{"claim":"…`. */ export function friendlyPreview(raw: unknown): string { if (!raw) return ""; const s = String(raw).trim(); const keyed = s.match( /"(?:claim|pitch|note|text|summary|result|answer|brief|description|title|content)"\s*:\s*"([^"\\]{8,})/i ); if (keyed && keyed[1]) return keyed[1].trim(); const firstLong = s.match(/"([^"\\]{24,})"/); if (firstLong && firstLong[1]) return firstLong[1].trim(); if (/^[[{]/.test(s)) { return s .replace(/[{}[\]"]/g, " ") .replace(/\s+/g, " ") .trim(); } return s; } /** Full, un-truncated content for the expanded view - pretty-printed if JSON. */ export function fullPreview(raw: unknown): string { if (raw == null) return ""; const s = String(raw); try { return JSON.stringify(JSON.parse(s), null, 2); } catch { return s; } } /** Join the text blocks of one transcript message into a single string. */ function messageText(m: TranscriptMessage): string { return (m.content || []) .filter((b) => b.type === "text" && b.text) .map((b) => b.text as string) .join("\n\n") .trim(); } /** * Derive an agent's full prompt and result from its fetched transcript: the * first user message carries the task prompt; the last assistant message that * has text carries the returned result. Either is "" when absent (e.g. a * schema-mode agent whose final turn is a tool call rather than text) - callers * fall back to the journal teaser in that case. */ export function extractPromptResult(messages: TranscriptMessage[]): { prompt: string; result: string; } { let prompt = ""; let result = ""; for (const m of messages || []) { if (m.type === "user" && !prompt) { const t = messageText(m); if (t) prompt = t; } else if (m.type === "assistant") { const t = messageText(m); if (t) result = t; // keep the last non-empty assistant text } } return { prompt, result }; } /** Per-agent transcript fetch state, keyed `${run_id}::${agentId}`. */ interface AgentTranscriptState { loading: boolean; prompt?: string; result?: string; error?: boolean; } export function WorkflowRunsPanel({ runs: controlledRuns, statusFilter, sessionId, hideSessionLink, }: Props) { const { t } = useTranslation("workflows"); const controlled = controlledRuns != null; const [fetchedRuns, setFetchedRuns] = useState([]); const [loading, setLoading] = useState(!controlled); const [expanded, setExpanded] = useState>(() => new Set()); const [phaseFilter, setPhaseFilter] = useState>({}); const [openResults, setOpenResults] = useState>(() => new Set()); // Full agent transcripts fetched on demand when a result row is expanded, // keyed `${run_id}::${agentId}`. The run journal only carries truncated // previews; the complete text lives in the per-agent transcript file. const [transcripts, setTranscripts] = useState>({}); const inflightRef = useRef>(new Set()); const loadTranscript = useCallback( async (sessionId: string, runId: string, agentId: string, key: string) => { if (inflightRef.current.has(key)) return; inflightRef.current.add(key); setTranscripts((prev) => ({ ...prev, [key]: { loading: true } })); try { const res = await api.sessions.transcript(sessionId, { agent_id: agentId, run_id: runId, limit: 200, }); const { prompt, result } = extractPromptResult(res.messages || []); setTranscripts((prev) => ({ ...prev, [key]: { loading: false, prompt, result } })); } catch { setTranscripts((prev) => ({ ...prev, [key]: { loading: false, error: true } })); } }, [] ); const fetchRuns = useCallback(async () => { if (controlled) return; try { const status = statusFilter === "active" ? "running" : statusFilter === "completed" ? "completed" : undefined; const res = await api.workflows.runs({ status, session_id: sessionId, limit: 200 }); setFetchedRuns(res.runs); } catch { /* leave previous runs in place */ } finally { setLoading(false); } }, [controlled, statusFilter, sessionId]); useEffect(() => { if (controlled) return; fetchRuns(); }, [controlled, fetchRuns]); // Live updates: debounce a refetch when a workflow row changes. const timerRef = useRef | null>(null); useEffect(() => { if (controlled) return; const handler = (msg: WSMessage) => { if (msg.type !== "workflow_upserted") return; if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(fetchRuns, 1500); }; const unsub = eventBus.subscribe(handler); return () => { unsub(); if (timerRef.current) clearTimeout(timerRef.current); }; }, [controlled, fetchRuns]); const runs = controlled ? controlledRuns : fetchedRuns; const toggle = (runId: string) => setExpanded((prev) => { const next = new Set(prev); if (next.has(runId)) next.delete(runId); else next.add(runId); return next; }); const setPhase = (runId: string, phase: string) => setPhaseFilter((prev) => ({ ...prev, [runId]: prev[runId] === phase ? null : phase })); const toggleResult = (key: string) => setOpenResults((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); if (!controlled && loading) { return (
{t("runs.loading")}
); } if (runs.length === 0) { return (
{t("runs.empty")}
); } return (
{runs.map((run) => { const isOpen = expanded.has(run.run_id); const running = run.status === "running" || run.status === "working"; // progress[] mixes phase markers and agents; only `workflow_agent` // entries are real agents. const agentRows = (run.progress || []).filter((p) => p.type === "workflow_agent"); const phaseTitles = (run.phases || []).map((p) => p.title || "").filter(Boolean); const sel = phaseFilter[run.run_id] || null; const shown = sel ? agentRows.filter((a) => a.phaseTitle === sel) : agentRows; const resultRows = shown.filter((a) => a.resultPreview); return (
{isOpen && (
{/* Clickable, colored phase filters */} {phaseTitles.length > 0 && (
{phaseTitles.map((title, i) => { const active = sel === title; return ( ); })} {sel && ( )}
)} {shown.length > 0 ? (
{shown.map((a: WorkflowProgressEntry, i) => ( ))}
{t("runs.col.agent")} {t("runs.col.phase")} {t("runs.col.state")} {t("runs.col.tokens")} {t("runs.col.tools")} {t("runs.col.duration")}
{a.label || a.agentType || a.agentId} {a.lastToolName && ( · {a.lastToolName} )} {a.phaseTitle || "-"} {t(`runs.status.${a.state}`, String(a.state || "-"))} {fmt(a.tokens || 0)} {a.toolCalls || 0} {a.durationMs != null ? formatMs(a.durationMs) : "-"}
) : (

{t("runs.noAgents")}

)} {/* Clickable, colored, expandable results - full content on click */} {resultRows.length > 0 && (
{t("runs.resultsLabel")} · {resultRows.length}
{resultRows.map((a, i) => { const key = `${run.run_id}::${a.agentId || i}`; const open = openResults.has(key); const ts = transcripts[key]; const hasFull = !!ts && !ts.loading && !ts.error; const fullPrompt = hasFull && ts.prompt ? ts.prompt : a.promptPreview ? String(a.promptPreview) : ""; const fullResult = hasFull && ts.result ? ts.result : fullPreview(a.resultPreview); return (
{open && (
{a.model && {a.model}} {t(`runs.status.${a.state}`, String(a.state || "-"))} {fmt(a.tokens || 0)} {t("runs.tokens")} {t("runs.tools", { count: a.toolCalls || 0 })} {a.durationMs != null && {formatMs(a.durationMs)}} {ts?.loading && ( {t("runs.loadingFull")} )}
{fullPrompt && (
{t("runs.promptLabel")}
                                    {fullPrompt}
                                  
)}
{t("runs.resultLabel")}
                                  {fullResult}
                                
)}
); })}
)}
)}
); })}
); }