feat(agent-team): React/Vite status-dashboard SPA
New web/ SPA (React 18 + Vite 5 + TypeScript + React Flow + dagre, all pinned, no
CDN). Dark, three-pane layout: summary top bar, filterable task list, center
auto-laid pipeline map, and a right drawer showing a selected task's history
through each node (timeline with timestamps/duration/cost + Q&A/verdicts/plan).
Map nodes color by live state, loop-backs render dashed, clicking a node filters
the list, selecting a task highlights its path. Polls /api/state every 4s;
topology fetched once. dist/ is gitignored (built on the Mac, rsynced).
2026-06-23 17:15:23 -04:00
|
|
|
import type { TaskDetail } from "../api";
|
2026-06-23 21:14:04 -04:00
|
|
|
import { Markdown } from "./Markdown";
|
feat(agent-team): React/Vite status-dashboard SPA
New web/ SPA (React 18 + Vite 5 + TypeScript + React Flow + dagre, all pinned, no
CDN). Dark, three-pane layout: summary top bar, filterable task list, center
auto-laid pipeline map, and a right drawer showing a selected task's history
through each node (timeline with timestamps/duration/cost + Q&A/verdicts/plan).
Map nodes color by live state, loop-backs render dashed, clicking a node filters
the list, selecting a task highlights its path. Polls /api/state every 4s;
topology fetched once. dist/ is gitignored (built on the Mac, rsynced).
2026-06-23 17:15:23 -04:00
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
detail: TaskDetail | null;
|
|
|
|
|
loading: boolean;
|
|
|
|
|
error: string | null;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fmtDuration(s: number | null): string {
|
|
|
|
|
if (s == null) return "—";
|
|
|
|
|
if (s < 60) return `${s.toFixed(1)}s`;
|
|
|
|
|
if (s < 3600) return `${(s / 60).toFixed(1)}m`;
|
|
|
|
|
return `${(s / 3600).toFixed(1)}h`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fmtCost(usd: number): string {
|
|
|
|
|
return usd > 0 ? `$${usd.toFixed(4)}` : "—";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Right-hand drawer: a task's journey through the pipeline (vertical timeline of
|
|
|
|
|
// task_transitions) plus Q&A, review verdicts, plan summary, and total spend.
|
|
|
|
|
export function TaskDrawer({ detail, loading, error, onClose }: Props) {
|
|
|
|
|
if (!detail && !loading && !error) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<section className="drawer" aria-label="task detail" role="complementary">
|
|
|
|
|
<div className="drawer-head">
|
|
|
|
|
<h2>Task history</h2>
|
|
|
|
|
<button className="drawer-close" aria-label="Close detail" onClick={onClose}>
|
|
|
|
|
✕
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{loading && <div className="drawer-loading">Loading…</div>}
|
|
|
|
|
{error && <div className="drawer-error">{error}</div>}
|
|
|
|
|
|
|
|
|
|
{detail && detail.ok && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="drawer-summary">
|
|
|
|
|
<div className="drawer-task">{detail.task}</div>
|
|
|
|
|
<div className="drawer-meta">
|
|
|
|
|
<span className={`badge ${detail.status}`}>{detail.status}</span>
|
|
|
|
|
<span className="mono">{detail.short_id}</span>
|
|
|
|
|
<span className="spend">spend {fmtCost(detail.total_usd)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{detail.partial && (
|
|
|
|
|
<div className="partial-banner">
|
|
|
|
|
Partial history — recorded since the v3 ledger; earlier steps are
|
|
|
|
|
reconstructed.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<ol className="timeline">
|
|
|
|
|
{detail.timeline.map((step, i) => {
|
|
|
|
|
const open = step.exited_at == null;
|
|
|
|
|
return (
|
|
|
|
|
<li className={`tl-step ${open ? "open" : ""}`} key={i}>
|
|
|
|
|
<span className="tl-dot" aria-hidden />
|
|
|
|
|
<div className="tl-body">
|
|
|
|
|
<div className="tl-node">{step.to_phase}</div>
|
|
|
|
|
<div className="tl-times">
|
|
|
|
|
<span>{step.entered_at ?? "—"}</span>
|
|
|
|
|
<span className="tl-dur">
|
|
|
|
|
{open ? "in progress" : fmtDuration(step.duration_s)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="tl-foot">
|
|
|
|
|
{step.status && <span className={`badge ${step.status}`}>{step.status}</span>}
|
|
|
|
|
<span className="tl-cost">{fmtCost(step.cost_usd)}</span>
|
|
|
|
|
{step.note && <span className="tl-note">{step.note}</span>}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</li>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
{detail.timeline.length === 0 && (
|
|
|
|
|
<li className="empty">No transitions recorded.</li>
|
|
|
|
|
)}
|
|
|
|
|
</ol>
|
|
|
|
|
|
|
|
|
|
{detail.qa_history.length > 0 && (
|
|
|
|
|
<details className="drawer-section" open>
|
|
|
|
|
<summary>Q&A ({detail.qa_history.length})</summary>
|
2026-06-23 21:14:04 -04:00
|
|
|
<QASection entries={detail.qa_history} />
|
feat(agent-team): React/Vite status-dashboard SPA
New web/ SPA (React 18 + Vite 5 + TypeScript + React Flow + dagre, all pinned, no
CDN). Dark, three-pane layout: summary top bar, filterable task list, center
auto-laid pipeline map, and a right drawer showing a selected task's history
through each node (timeline with timestamps/duration/cost + Q&A/verdicts/plan).
Map nodes color by live state, loop-backs render dashed, clicking a node filters
the list, selecting a task highlights its path. Polls /api/state every 4s;
topology fetched once. dist/ is gitignored (built on the Mac, rsynced).
2026-06-23 17:15:23 -04:00
|
|
|
</details>
|
|
|
|
|
)}
|
|
|
|
|
{detail.review_verdicts.length > 0 && (
|
|
|
|
|
<details className="drawer-section">
|
|
|
|
|
<summary>Review verdicts ({detail.review_verdicts.length})</summary>
|
2026-06-23 21:14:04 -04:00
|
|
|
<VerdictsSection entries={detail.review_verdicts} />
|
feat(agent-team): React/Vite status-dashboard SPA
New web/ SPA (React 18 + Vite 5 + TypeScript + React Flow + dagre, all pinned, no
CDN). Dark, three-pane layout: summary top bar, filterable task list, center
auto-laid pipeline map, and a right drawer showing a selected task's history
through each node (timeline with timestamps/duration/cost + Q&A/verdicts/plan).
Map nodes color by live state, loop-backs render dashed, clicking a node filters
the list, selecting a task highlights its path. Polls /api/state every 4s;
topology fetched once. dist/ is gitignored (built on the Mac, rsynced).
2026-06-23 17:15:23 -04:00
|
|
|
</details>
|
|
|
|
|
)}
|
|
|
|
|
{detail.plan && (
|
|
|
|
|
<details className="drawer-section">
|
|
|
|
|
<summary>Plan</summary>
|
2026-06-23 21:14:04 -04:00
|
|
|
<PlanSection plan={detail.plan} />
|
feat(agent-team): React/Vite status-dashboard SPA
New web/ SPA (React 18 + Vite 5 + TypeScript + React Flow + dagre, all pinned, no
CDN). Dark, three-pane layout: summary top bar, filterable task list, center
auto-laid pipeline map, and a right drawer showing a selected task's history
through each node (timeline with timestamps/duration/cost + Q&A/verdicts/plan).
Map nodes color by live state, loop-backs render dashed, clicking a node filters
the list, selecting a task highlights its path. Polls /api/state every 4s;
topology fetched once. dist/ is gitignored (built on the Mac, rsynced).
2026-06-23 17:15:23 -04:00
|
|
|
</details>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{detail && !detail.ok && (
|
|
|
|
|
<div className="drawer-error">{detail.error ?? "Could not load task."}</div>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-06-23 21:14:04 -04:00
|
|
|
|
|
|
|
|
function asText(v: unknown): string {
|
|
|
|
|
return typeof v === "string" ? v : v == null ? "" : JSON.stringify(v, null, 2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Q&A: each entry is the answer text for a turn (a string), or an older
|
|
|
|
|
// {turn, question_id, answer} dict. Render each as a turn card with the answer
|
|
|
|
|
// as markdown so numbered sub-answers / bold render readably.
|
|
|
|
|
function QASection({ entries }: { entries: unknown[] }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="qa">
|
|
|
|
|
{entries.map((entry, i) => {
|
|
|
|
|
const obj =
|
|
|
|
|
entry && typeof entry === "object" ? (entry as Record<string, unknown>) : null;
|
|
|
|
|
const turn = obj && "turn" in obj ? Number(obj.turn) : i;
|
|
|
|
|
const answer = obj && "answer" in obj ? asText(obj.answer) : asText(entry);
|
|
|
|
|
return (
|
|
|
|
|
<div className="qa-card" key={i}>
|
|
|
|
|
<div className="qa-turn">Turn {turn + 1}</div>
|
|
|
|
|
<Markdown>{answer}</Markdown>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Review verdicts: each {verdict, round_index, outcome, findings} → a card with a
|
|
|
|
|
// verdict badge + round + outcome header and the findings rendered as markdown.
|
|
|
|
|
function VerdictsSection({ entries }: { entries: unknown[] }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="verdicts">
|
|
|
|
|
{entries.map((entry, i) => {
|
|
|
|
|
const v =
|
|
|
|
|
entry && typeof entry === "object" ? (entry as Record<string, unknown>) : {};
|
|
|
|
|
const verdict = asText(v.verdict) || "verdict";
|
|
|
|
|
const round = "round_index" in v ? Number(v.round_index) : i + 1;
|
|
|
|
|
const outcome = asText(v.outcome);
|
|
|
|
|
const findings = asText(v.findings);
|
|
|
|
|
const cls = verdict.replace(/[^a-z_]/gi, "_").toLowerCase();
|
|
|
|
|
return (
|
|
|
|
|
<div className="verdict-card" key={i}>
|
|
|
|
|
<div className="verdict-head">
|
|
|
|
|
<span className="verdict-round">Round {round}</span>
|
|
|
|
|
<span className={`badge ${cls}`}>{verdict.replace(/_/g, " ")}</span>
|
|
|
|
|
{outcome && <span className="verdict-outcome">{outcome.replace(/_/g, " ")}</span>}
|
|
|
|
|
</div>
|
|
|
|
|
{findings && <Markdown>{findings}</Markdown>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Plan: {summary, phases:[{name, steps:[...]}]} → summary (markdown) + a phase
|
|
|
|
|
// list with nested steps. Any extra scalar keys render as a small label/value
|
|
|
|
|
// list; an unrecognized shape falls back to a collapsed JSON view.
|
|
|
|
|
function PlanSection({ plan }: { plan: Record<string, unknown> }) {
|
|
|
|
|
const summary = asText(plan.summary);
|
|
|
|
|
const phases = Array.isArray(plan.phases) ? plan.phases : null;
|
|
|
|
|
const extra = Object.entries(plan).filter(
|
|
|
|
|
([k, val]) =>
|
|
|
|
|
k !== "summary" && k !== "phases" && (typeof val !== "object" || val === null),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!summary && !phases) {
|
|
|
|
|
return (
|
|
|
|
|
<pre className="json">{JSON.stringify(plan, null, 2)}</pre>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="plan">
|
|
|
|
|
{summary && <Markdown>{summary}</Markdown>}
|
|
|
|
|
{phases && (
|
|
|
|
|
<ol className="plan-phases">
|
|
|
|
|
{phases.map((p, i) => {
|
|
|
|
|
const ph =
|
|
|
|
|
p && typeof p === "object" ? (p as Record<string, unknown>) : {};
|
|
|
|
|
const name = asText(ph.name) || `Phase ${i + 1}`;
|
|
|
|
|
const steps = Array.isArray(ph.steps) ? ph.steps : [];
|
|
|
|
|
return (
|
|
|
|
|
<li className="plan-phase" key={i}>
|
|
|
|
|
<div className="plan-phase-name">{name}</div>
|
|
|
|
|
{steps.length > 0 && (
|
|
|
|
|
<ul className="plan-steps">
|
|
|
|
|
{steps.map((s, j) => (
|
|
|
|
|
<li key={j}>{asText(s)}</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
</li>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</ol>
|
|
|
|
|
)}
|
|
|
|
|
{extra.length > 0 && (
|
|
|
|
|
<dl className="plan-extra">
|
|
|
|
|
{extra.map(([k, val]) => (
|
|
|
|
|
<div className="plan-extra-row" key={k}>
|
|
|
|
|
<dt>{k}</dt>
|
|
|
|
|
<dd>{asText(val)}</dd>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</dl>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|