7357070fb9
Deletes desktop/ (Electron wrapper), deployments/ (Helm/Kustomize/ Terraform/CI for cloud deploy), and monitoring/ (Prometheus + Grafana stack) along with DESKTOP.md, DEPLOYMENT.md, docker-compose.full.yml, their npm scripts, and every dangling reference across README, ARCHITECTURE, INSTALL, SETUP, docs/, and the repeated per-file MODULE_GUIDE "Observability" boilerplate comment. The GET /api/metrics endpoint itself is untouched — it's the dashboard's own route, not part of the removed monitoring stack.
608 lines
27 KiB
TypeScript
608 lines
27 KiB
TypeScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
/* =============================================================================
|
|
* 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.
|
|
*
|
|
* ## 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<string, string> = {
|
|
running: "bg-status-warning/15 text-status-warning border-status-warning/30",
|
|
working: "bg-status-warning/15 text-status-warning border-status-warning/30",
|
|
queued: "bg-surface-4/15 text-fg-secondary border-border-light/30",
|
|
completed: "bg-status-success/15 text-status-success border-status-success/30",
|
|
done: "bg-status-success/15 text-status-success border-status-success/30",
|
|
success: "bg-status-success/15 text-status-success border-status-success/30",
|
|
error: "bg-status-danger/15 text-status-danger border-status-danger/30",
|
|
failed: "bg-status-danger/15 text-status-danger border-status-danger/30",
|
|
};
|
|
|
|
function statusClass(status: string): string {
|
|
return STATUS_STYLES[status] || "bg-surface-4/15 text-fg-secondary border-border-light/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-status-warning/15 text-status-warning border-status-warning/40",
|
|
"bg-status-success/15 text-status-success border-status-success/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-surface-4/15 text-fg-secondary border-border-light/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<WorkflowRun[]>([]);
|
|
const [loading, setLoading] = useState(!controlled);
|
|
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
|
const [phaseFilter, setPhaseFilter] = useState<Record<string, string | null>>({});
|
|
const [openResults, setOpenResults] = useState<Set<string>>(() => 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<Record<string, AgentTranscriptState>>({});
|
|
const inflightRef = useRef<Set<string>>(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<ReturnType<typeof setTimeout> | 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 (
|
|
<div className="flex items-center justify-center gap-2 py-8 text-sm text-fg-muted">
|
|
<Loader2 className="w-4 h-4 animate-spin text-violet-400" />
|
|
<span className="animate-pulse">{t("runs.loading")}</span>
|
|
</div>
|
|
);
|
|
}
|
|
if (runs.length === 0) {
|
|
return (
|
|
<div className="text-sm text-fg-muted flex items-center gap-2">
|
|
<Workflow className="w-4 h-4 text-fg-muted" />
|
|
{t("runs.empty")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{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 (
|
|
<div
|
|
key={run.run_id}
|
|
className="rounded-lg border border-border bg-card/40 overflow-hidden"
|
|
>
|
|
<button
|
|
onClick={() => toggle(run.run_id)}
|
|
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-surface-2/30 transition-colors"
|
|
aria-expanded={isOpen}
|
|
>
|
|
{isOpen ? (
|
|
<ChevronDown className="w-4 h-4 text-fg-muted flex-shrink-0" />
|
|
) : (
|
|
<ChevronRight className="w-4 h-4 text-fg-muted flex-shrink-0" />
|
|
)}
|
|
{running ? (
|
|
<Loader2 className="w-4 h-4 text-status-warning flex-shrink-0 animate-spin" />
|
|
) : (
|
|
<Workflow className="w-4 h-4 text-violet-400 flex-shrink-0" />
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-sm font-medium text-fg-secondary truncate">
|
|
{run.name || run.run_id}
|
|
</span>
|
|
<span className={`badge text-[10px] border ${statusClass(run.status)}`}>
|
|
{t(`runs.status.${run.status}`, run.status)}
|
|
</span>
|
|
{run.default_model && (
|
|
<span className="text-[10px] font-mono text-fg-muted">{run.default_model}</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-0.5 text-[11px] text-fg-muted flex-wrap">
|
|
<span>{t("runs.agents", { count: run.agent_count })}</span>
|
|
<span>{t("runs.tools", { count: run.total_tool_calls })}</span>
|
|
<span>
|
|
{fmt(run.total_tokens)} {t("runs.tokens")}
|
|
</span>
|
|
{run.duration_ms != null && <span>{formatMs(run.duration_ms)}</span>}
|
|
{run.started_at && <span>{timeAgo(run.started_at)}</span>}
|
|
</div>
|
|
</div>
|
|
{!hideSessionLink && (
|
|
<Link
|
|
to={`/sessions/${encodeURIComponent(run.session_id)}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="text-fg-muted hover:text-violet-400 transition-colors flex-shrink-0"
|
|
title={t("runs.openSession")}
|
|
>
|
|
<ExternalLink className="w-3.5 h-3.5" />
|
|
</Link>
|
|
)}
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div className="px-3 pb-3 pt-3 border-t border-border/60 space-y-3">
|
|
{/* Clickable, colored phase filters */}
|
|
{phaseTitles.length > 0 && (
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
<Layers className="w-3.5 h-3.5 text-fg-muted" />
|
|
{phaseTitles.map((title, i) => {
|
|
const active = sel === title;
|
|
return (
|
|
<button
|
|
key={i}
|
|
onClick={() => setPhase(run.run_id, title)}
|
|
className={`badge text-[10px] border transition ${phaseColor(phaseTitles, title)} ${
|
|
active
|
|
? "ring-1 ring-white/50"
|
|
: sel
|
|
? "opacity-40 hover:opacity-100"
|
|
: "hover:ring-1 hover:ring-white/20"
|
|
}`}
|
|
title={t("runs.filterPhase")}
|
|
>
|
|
{title}
|
|
</button>
|
|
);
|
|
})}
|
|
{sel && (
|
|
<button
|
|
onClick={() => setPhase(run.run_id, sel)}
|
|
className="text-[10px] text-fg-muted hover:text-fg-secondary underline"
|
|
>
|
|
{t("runs.clearFilter")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{shown.length > 0 ? (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-[11px]">
|
|
<thead>
|
|
<tr className="text-fg-muted text-left border-b border-border">
|
|
<th className="py-1 pr-3 font-medium">{t("runs.col.agent")}</th>
|
|
<th className="py-1 pr-3 font-medium">{t("runs.col.phase")}</th>
|
|
<th className="py-1 pr-3 font-medium">{t("runs.col.state")}</th>
|
|
<th className="py-1 pr-3 font-medium text-right">
|
|
{t("runs.col.tokens")}
|
|
</th>
|
|
<th className="py-1 pr-3 font-medium text-right">
|
|
{t("runs.col.tools")}
|
|
</th>
|
|
<th className="py-1 pr-3 font-medium text-right">
|
|
{t("runs.col.duration")}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{shown.map((a: WorkflowProgressEntry, i) => (
|
|
<tr key={a.agentId || i} className="border-b border-border/40">
|
|
<td className="py-1 pr-3 text-fg-secondary">
|
|
{a.label || a.agentType || a.agentId}
|
|
{a.lastToolName && (
|
|
<span className="text-fg-muted font-mono ml-1">
|
|
· {a.lastToolName}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="py-1 pr-3">
|
|
<span
|
|
className={`badge text-[10px] border ${phaseColor(phaseTitles, a.phaseTitle)}`}
|
|
>
|
|
{a.phaseTitle || "-"}
|
|
</span>
|
|
</td>
|
|
<td className="py-1 pr-3">
|
|
<span
|
|
className={`badge text-[10px] border ${statusClass(String(a.state || ""))}`}
|
|
>
|
|
{t(`runs.status.${a.state}`, String(a.state || "-"))}
|
|
</span>
|
|
</td>
|
|
<td className="py-1 pr-3 text-right text-fg-secondary">
|
|
{fmt(a.tokens || 0)}
|
|
</td>
|
|
<td className="py-1 pr-3 text-right text-fg-secondary">
|
|
{a.toolCalls || 0}
|
|
</td>
|
|
<td className="py-1 pr-3 text-right text-fg-secondary">
|
|
{a.durationMs != null ? formatMs(a.durationMs) : "-"}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<p className="text-[11px] text-fg-muted">{t("runs.noAgents")}</p>
|
|
)}
|
|
|
|
{/* Clickable, colored, expandable results - full content on click */}
|
|
{resultRows.length > 0 && (
|
|
<div className="space-y-1.5">
|
|
<div className="text-[10px] font-medium uppercase tracking-wider text-fg-muted">
|
|
{t("runs.resultsLabel")}
|
|
<span className="ml-1 text-fg-muted">· {resultRows.length}</span>
|
|
</div>
|
|
{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 (
|
|
<div
|
|
key={key}
|
|
className="rounded border border-border/70 bg-surface-0/30 overflow-hidden"
|
|
>
|
|
<button
|
|
onClick={() => {
|
|
if (!open && a.agentId) {
|
|
loadTranscript(run.session_id, run.run_id, a.agentId, key);
|
|
}
|
|
toggleResult(key);
|
|
}}
|
|
className="w-full flex items-center gap-2 px-2 py-1.5 text-left hover:bg-surface-2/40 transition-colors"
|
|
aria-expanded={open}
|
|
>
|
|
{open ? (
|
|
<ChevronDown className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
|
|
) : (
|
|
<ChevronRight className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
|
|
)}
|
|
<span
|
|
className={`badge text-[10px] border flex-shrink-0 ${phaseColor(phaseTitles, a.phaseTitle)}`}
|
|
>
|
|
{a.label || a.agentType || a.agentId}
|
|
</span>
|
|
{!open && (
|
|
<span className="text-[11px] text-fg-muted leading-snug min-w-0">
|
|
{truncate(friendlyPreview(a.resultPreview), 160)}
|
|
</span>
|
|
)}
|
|
</button>
|
|
{open && (
|
|
<div className="px-2.5 pb-2.5 pt-0.5 space-y-2">
|
|
<div className="flex flex-wrap items-center gap-2 text-[10px] text-fg-muted">
|
|
{a.model && <span className="font-mono">{a.model}</span>}
|
|
<span
|
|
className={`badge border ${statusClass(String(a.state || ""))}`}
|
|
>
|
|
{t(`runs.status.${a.state}`, String(a.state || "-"))}
|
|
</span>
|
|
<span>
|
|
{fmt(a.tokens || 0)} {t("runs.tokens")}
|
|
</span>
|
|
<span>{t("runs.tools", { count: a.toolCalls || 0 })}</span>
|
|
{a.durationMs != null && <span>{formatMs(a.durationMs)}</span>}
|
|
{ts?.loading && (
|
|
<span className="flex items-center gap-1 text-violet-400">
|
|
<Loader2 className="w-3 h-3 animate-spin" />
|
|
{t("runs.loadingFull")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{fullPrompt && (
|
|
<div>
|
|
<div className="text-[10px] uppercase tracking-wider text-fg-muted mb-0.5">
|
|
{t("runs.promptLabel")}
|
|
</div>
|
|
<pre className="text-[11px] text-fg-secondary whitespace-pre-wrap break-words bg-black/30 rounded p-2 max-h-48 overflow-auto">
|
|
{fullPrompt}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<div className="text-[10px] uppercase tracking-wider text-fg-muted mb-0.5">
|
|
{t("runs.resultLabel")}
|
|
</div>
|
|
<pre className="text-[11px] text-fg-secondary whitespace-pre-wrap break-words bg-black/30 rounded p-2 max-h-96 overflow-auto">
|
|
{fullResult}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|