This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
orchestrator/agent-team/web/src/components/TaskDrawer.tsx

221 lines
7.8 KiB
TypeScript
Raw Normal View History

import type { TaskDetail } from "../api";
import { Markdown } from "./Markdown";
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&amp;A ({detail.qa_history.length})</summary>
<QASection entries={detail.qa_history} />
</details>
)}
{detail.review_verdicts.length > 0 && (
<details className="drawer-section">
<summary>Review verdicts ({detail.review_verdicts.length})</summary>
<VerdictsSection entries={detail.review_verdicts} />
</details>
)}
{detail.plan && (
<details className="drawer-section">
<summary>Plan</summary>
<PlanSection plan={detail.plan} />
</details>
)}
</>
)}
{detail && !detail.ok && (
<div className="drawer-error">{detail.error ?? "Could not load task."}</div>
)}
</section>
);
}
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>
);
}