Files
clawmates/frontend/src/components/dashboard/MissionCanvas.tsx
T
Omar SobhandClaude Opus 5 fe57ce4ed1 feat(missions): surface goal conditions and per-pass verdicts in the UI
Makes the completion evaluator usable and observable.

- GET /api/missions/{id}/phases/{phase_id}/evaluations returns every verdict
  for a phase, newest pass first, scoped like the summary endpoint.
- MissionPhase gains done_when / max_iterations / iteration, so the phase card
  can show what the phase is working toward and which pass it is on.
- PhaseStatus gains 'evaluating' (amber) -- the state between "runs finished"
  and "phase done" that only conditioned phases enter.
- New PhaseGoalStrip renders on the phase card, and renders NOTHING for phases
  without a condition so unconditioned missions look exactly as before. It
  polls only while the phase is running or being judged.
- Mission wizard step 2 gains the condition + a max-passes field.

Two deliberate emphases in the UI:

The evaluator's `reason` is the most prominent element, because it is both the
explanation of why a phase iterated and the literal text handed back to the
agents as guidance -- it is what tells an operator whether the condition is
written well.

The hint copy states the constraint that actually governs whether a condition
works: the judge cannot run commands, it only reads what the agents wrote, so
the condition has to be provable from their output. "cargo test reported 0
failures" works; "the code is well factored" does not. Getting this wrong is
the difference between a phase that converges and one that burns every pass.

An evaluator error is rendered distinctly from a negative verdict, so a judge
outage doesn't read as a judgement on the work.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-30 13:10:57 -07:00

1342 lines
46 KiB
TypeScript

"use client";
// MissionCanvas — unified detail view for the missions tier. Tabs:
// Overview · title, description, template kind, team, repo, schedule
// Phases · ordered timeline of phase kinds + statuses
// Tasks · INT-XX cards (Slice 5 fills these; Slice 2 renders shell)
// Artifacts · MD/PDF/benchmark/security files with PDF-render status
//
// This replaces ResearchCanvas + LoopsCanvas after Slice 9's cutover.
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
ChevronDown,
ChevronUp,
FileText,
Pencil,
Play,
Plus,
RefreshCw,
Sparkles,
Trash2,
} from "lucide-react";
import {
deleteMission,
getMission,
listMissionRuns,
refineMission,
retryMissionPhase,
setMissionDescription,
setMissionStatus,
triggerBenchmark,
triggerSecurityScan,
type MissionDetail,
type MissionRunSummary,
type MissionStatus,
type PhaseKind,
type PhaseStatus,
type RefineResult,
type TaskStatus,
type TemplateKind,
} from "@/lib/api/missions";
import { EditMissionModal } from "./EditMissionModal";
import { MarkdownBlock } from "./MarkdownBlock";
import { MissionLiveEvents } from "./MissionLiveEvents";
import { MissionLivePane } from "./MissionLivePane";
import { MissionOutputReader } from "./MissionOutputReader";
import { MissionTeamTab } from "./MissionTeamTab";
import { MissionWizard } from "./MissionWizard";
import { PhaseGoalStrip } from "./PhaseGoalStrip";
import { PhaseRunsList } from "./PhaseRunsList";
import { PhaseSummaryCard } from "./PhaseSummaryCard";
import { RefineDiffModal } from "./RefineDiffModal";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
const STATUS_COLOR: Record<MissionStatus, string> = {
draft: "#8a8a92",
running: "#5ec8d8",
completed: "#5fd08a",
failed: "#ff8a7a",
cancelled: "#6a6a72",
};
const PHASE_STATUS_COLOR: Record<PhaseStatus, string> = {
pending: "#6a6a72",
running: "#5ec8d8",
// Amber: work is done but the completion condition is being judged.
evaluating: "#e8b465",
completed: "#5fd08a",
failed: "#ff8a7a",
skipped: "#8a8a92",
};
const TASK_STATUS_COLOR: Record<TaskStatus, string> = {
created: "#8a8a92",
working: "#5ec8d8",
validating: "#ffb44a",
complete: "#5fd08a",
failed: "#ff8a7a",
};
const PHASE_LABEL: Record<PhaseKind, string> = {
research: "Research",
coding: "Coding",
benchmark: "Benchmark",
security_scan: "Security scan",
};
const TEMPLATE_LABEL: Record<TemplateKind, string> = {
research_only: "Research only",
research_and_code: "Research + Coding Loop",
security_hardening: "Security Hardening",
refactor: "Refactor",
benchmark: "Benchmark",
custom: "Custom",
};
// Three primary tabs, each with a shallow segmented sub-view. The old
// shape was eight flat tabs (overview/phases/tasks/team/live/artifacts/
// benchmarks/pane) that mixed lifecycle, work items, people, telemetry,
// outputs and infra at one level — so nothing told you where the actual
// deliverable lived (it was buried under phases → run → turn).
//
// RUN what is happening · phases · tasks · live
// OUTPUT what came out of it · documents · artifacts · benchmarks
// SETUP how it is configured · overview · team · pane
type Tab = "run" | "output" | "setup";
type RunSub = "phases" | "tasks" | "live";
type OutputSub = "documents" | "artifacts" | "benchmarks";
type SetupSub = "overview" | "team" | "pane";
export function MissionCanvas({
selectedId,
refreshKey,
onChanged,
onSelect,
onDeleted,
onOpenClaw,
}: {
selectedId: string | null;
refreshKey: number;
onChanged: () => void;
onSelect?: (id: string) => void;
onDeleted?: () => void;
/** Cross-tier navigation — jumps to AGENT tier with this claw selected. */
onOpenClaw?: (clawId: string) => void;
}) {
const [mission, setMission] = useState<MissionDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("run");
const [runSub, setRunSub] = useState<RunSub>("phases");
const [outputSub, setOutputSub] = useState<OutputSub>("documents");
const [setupSub, setSetupSub] = useState<SetupSub>("overview");
const [launching, setLaunching] = useState(false);
const [refining, setRefining] = useState(false);
const [refineDiff, setRefineDiff] = useState<RefineResult | null>(null);
const [accepting, setAccepting] = useState(false);
const [phaseBusy, setPhaseBusy] = useState<string | null>(null);
const [pdfPreviewId, setPdfPreviewId] = useState<string | null>(null);
const [wizardOpen, setWizardOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [deleteBusy, setDeleteBusy] = useState(false);
const [runs, setRuns] = useState<MissionRunSummary[]>([]);
const [lastLoadedAt, setLastLoadedAt] = useState<Date | null>(null);
// Persist the collapsed state across mission switches so the operator
// can keep the header hidden once they've read it.
const [headerCollapsed, setHeaderCollapsed] = useState<boolean>(() => {
if (typeof window === "undefined") return false;
return window.localStorage.getItem("cm.mission.headerCollapsed") === "1";
});
const toggleHeader = useCallback(() => {
setHeaderCollapsed((v) => {
const next = !v;
if (typeof window !== "undefined") {
window.localStorage.setItem("cm.mission.headerCollapsed", next ? "1" : "0");
}
return next;
});
}, []);
const load = useCallback(async () => {
if (!selectedId) {
setMission(null);
return;
}
setLoading(true);
setError(null);
try {
const [m, r] = await Promise.all([
getMission(selectedId),
listMissionRuns(selectedId).catch(() => ({ runs: [] })),
]);
setMission(m);
setRuns(r.runs);
setLastLoadedAt(new Date());
} catch (e) {
setError(e instanceof Error ? e.message : "load failed");
} finally {
setLoading(false);
}
}, [selectedId]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load, refreshKey]);
// Auto-refresh every 3s while the mission is running so Phases +
// Tasks + Artifacts tabs surface progress without hitting Refresh.
// Stops polling once the mission reaches a terminal state.
useEffect(() => {
if (mission?.status !== "running") return;
const t = setInterval(() => {
void load();
}, 3000);
return () => clearInterval(t);
}, [mission?.status, load]);
const refine = useCallback(async () => {
if (!mission) return;
setRefining(true);
setError(null);
try {
const result = await refineMission(mission.id);
setRefineDiff(result);
} catch (e) {
setError(e instanceof Error ? e.message : "refine failed");
} finally {
setRefining(false);
}
}, [mission]);
const acceptRefine = useCallback(async () => {
if (!mission || !refineDiff) return;
setAccepting(true);
setError(null);
try {
await setMissionDescription(mission.id, refineDiff.refined);
setRefineDiff(null);
onChanged();
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "accept failed");
} finally {
setAccepting(false);
}
}, [mission, refineDiff, onChanged, load]);
const undoRefine = useCallback(async () => {
if (!mission || !refineDiff) return;
setAccepting(true);
setError(null);
try {
await setMissionDescription(mission.id, refineDiff.original);
setRefineDiff(null);
onChanged();
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "undo failed");
} finally {
setAccepting(false);
}
}, [mission, refineDiff, onChanged, load]);
const runSecurityScan = useCallback(
async (phaseId: string) => {
if (!mission) return;
setPhaseBusy(`sec:${phaseId}`);
setError(null);
try {
await triggerSecurityScan(mission.id, phaseId);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "security scan failed");
} finally {
setPhaseBusy(null);
}
},
[mission, load],
);
const runBenchmark = useCallback(
async (phaseId: string, slot: "baseline" | "after") => {
if (!mission) return;
setPhaseBusy(`bench:${phaseId}:${slot}`);
setError(null);
try {
await triggerBenchmark(
mission.id,
phaseId,
slot,
slot === "after" ? Math.max(1, mission.benchmarks.length) : undefined,
);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "benchmark failed");
} finally {
setPhaseBusy(null);
}
},
[mission, load],
);
const launch = useCallback(async () => {
if (!mission) return;
setLaunching(true);
try {
await setMissionStatus(mission.id, "running");
onChanged();
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "launch failed");
} finally {
setLaunching(false);
}
}, [mission, onChanged, load]);
// Index runs by phase_id so the phase card can surface the run's
// status + error text inline. Newest-first ordering from the API
// means the first entry per phase is the most recent attempt.
const runsByPhase = useMemo(() => {
const idx = new Map<string, MissionRunSummary[]>();
for (const r of runs) {
if (!r.mission_phase_id) continue;
const list = idx.get(r.mission_phase_id) ?? [];
list.push(r);
idx.set(r.mission_phase_id, list);
}
return idx;
}, [runs]);
const orderedPhases = useMemo(
() =>
mission
? [...mission.phases].sort((a, b) => a.order_idx - b.order_idx)
: [],
[mission],
);
if (!selectedId) {
return (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#6a6a72",
fontSize: 13,
padding: 24,
textAlign: "center",
}}
>
Pick a mission from the left, or hit + to create one.
</div>
);
}
if (loading && !mission) {
return (
<div style={{ padding: 20, fontFamily: mono, color: "#5ec8d8" }}>
Loading
</div>
);
}
if (error) {
return (
<div style={{ padding: 20, color: "#ff8a7a", fontSize: 12 }}>{error}</div>
);
}
if (!mission) return null;
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
<div
style={{
flex: "none",
padding: "16px 22px 12px",
borderBottom: "1px solid rgba(255,255,255,.06)",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span
style={{
width: 9,
height: 9,
borderRadius: "50%",
background: STATUS_COLOR[mission.status],
}}
/>
<span
style={{
fontFamily: mono,
fontSize: 10.5,
letterSpacing: ".14em",
color: STATUS_COLOR[mission.status],
textTransform: "uppercase",
}}
>
{mission.status}
</span>
<span
style={{
fontFamily: mono,
fontSize: 10.5,
letterSpacing: ".12em",
color: "#7cd6e0",
marginLeft: 8,
}}
>
{TEMPLATE_LABEL[mission.template_kind] ?? mission.template_kind}
</span>
<div style={{ marginLeft: "auto", display: "flex", gap: 6 }}>
<button
type="button"
onClick={() => setWizardOpen(true)}
title="Create a new mission"
aria-label="Add mission"
style={iconBtn}
>
<Plus size={13} />
</button>
{mission.status === "draft" && (
<button
type="button"
onClick={() => setEditOpen(true)}
title="Edit title + description"
aria-label="Edit mission"
style={iconBtn}
>
<Pencil size={13} />
</button>
)}
<button
type="button"
onClick={async () => {
if (deleteBusy) return;
const ok = window.confirm(
`Delete mission "${mission.title}"? This cascades to its phases, tasks, artifacts, and benchmark snapshots.`,
);
if (!ok) return;
setDeleteBusy(true);
try {
await deleteMission(mission.id);
onDeleted?.();
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "delete failed");
} finally {
setDeleteBusy(false);
}
}}
disabled={deleteBusy}
title="Delete this mission"
aria-label="Delete mission"
style={{
...iconBtn,
color: "#ff8a7a",
borderColor: "rgba(255,138,122,.35)",
opacity: deleteBusy ? 0.5 : 1,
}}
>
<Trash2 size={13} />
</button>
{mission.status === "draft" && (
<button
type="button"
onClick={refine}
disabled={refining || !mission.description?.trim()}
title={
mission.description?.trim()
? "Refine the description into a sectioned brief"
: "Add a description first"
}
aria-label="Refine"
style={{
...secondaryBtn,
opacity: refining || !mission.description?.trim() ? 0.5 : 1,
}}
>
<Sparkles size={13} style={{ marginRight: 4 }} />
{refining ? "Refining…" : "Refine"}
</button>
)}
{lastLoadedAt && (
<span
style={{
fontSize: 10,
color: "#6a6a72",
fontFamily: mono,
}}
title={lastLoadedAt.toISOString()}
>
updated {lastLoadedAt.toLocaleTimeString()}
</span>
)}
<button
type="button"
onClick={load}
title="Refresh"
aria-label="Refresh"
style={{ ...iconBtn, opacity: loading ? 0.6 : 1 }}
>
<RefreshCw
size={13}
style={loading ? { animation: "cm-spin 1s linear infinite" } : undefined}
/>
</button>
{mission.status === "draft" && (() => {
// Launch is enabled when we have SOMETHING that can
// materialize agents:
// - team_id set (already materialized)
// - team_template_id set (legacy single-team path)
// - config.phase_teams has entries (new multi-team wizard path)
const phaseTeams =
(mission.config as { phase_teams?: Record<string, string[]> })
?.phase_teams;
const hasPhaseTeams =
phaseTeams &&
Object.values(phaseTeams).some((arr) => arr.length > 0);
const hasTeam =
mission.team_id !== null ||
mission.team_template_id !== null ||
Boolean(hasPhaseTeams);
const disabled = launching || !hasTeam;
return (
<button
type="button"
onClick={launch}
disabled={disabled}
title={
!hasTeam
? "Cannot launch: mission has no team + no team template. Delete and recreate with a template picked in step 3, or attach a team via the API."
: "Launch this mission"
}
style={{
...primaryBtn,
opacity: disabled ? 0.5 : 1,
cursor: disabled ? "not-allowed" : "pointer",
}}
>
<Play size={13} style={{ marginRight: 4 }} />
{launching
? "Launching…"
: hasTeam
? "Launch"
: "No team"}
</button>
);
})()}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<h1 style={{ margin: 0, fontSize: 20, color: "#f3f3f5", flex: 1, minWidth: 0 }}>
{mission.title}
</h1>
{mission.description && (
<button
type="button"
onClick={toggleHeader}
title={headerCollapsed ? "Expand description" : "Collapse description"}
aria-label={headerCollapsed ? "Expand description" : "Collapse description"}
style={{
...iconBtn,
flex: "none",
}}
>
{headerCollapsed ? <ChevronDown size={13} /> : <ChevronUp size={13} />}
</button>
)}
</div>
{mission.description && !headerCollapsed && (
// Clipped, NOT scrollable — a scroll container here was a fourth
// nested scrollbar above the content area. The full text lives in
// Setup → Overview.
<div
style={{
marginTop: 4,
maxHeight: 92,
overflow: "hidden",
paddingRight: 8,
maskImage:
"linear-gradient(to bottom, #000 60%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, #000 60%, transparent 100%)",
}}
>
<MarkdownBlock source={mission.description} />
</div>
)}
{refineDiff && (
<RefineDiffModal
original={refineDiff.original}
refined={refineDiff.refined}
busy={accepting}
onAccept={acceptRefine}
onCancel={() => setRefineDiff(null)}
onUndoAfterAccept={undoRefine}
/>
)}
{wizardOpen && (
<MissionWizard
onClose={() => setWizardOpen(false)}
onCreated={(id) => {
setWizardOpen(false);
onSelect?.(id);
onChanged();
}}
/>
)}
{editOpen && (
<EditMissionModal
mission={mission}
onClose={() => setEditOpen(false)}
onSaved={async () => {
setEditOpen(false);
onChanged();
await load();
}}
/>
)}
{/* Primary tabs — three, not eight. */}
<div
style={{
display: "flex",
gap: 4,
marginTop: 6,
overflowX: "auto",
paddingBottom: 2,
scrollbarWidth: "thin",
}}
>
{(["run", "output", "setup"] as Tab[]).map((t) => {
const active = tab === t;
return (
<button
key={t}
type="button"
onClick={() => setTab(t)}
style={{
padding: "6px 16px",
borderRadius: 8,
border: `1px solid ${active ? "rgba(255,138,122,.5)" : "rgba(255,255,255,.08)"}`,
background: active ? "rgba(255,138,122,.08)" : "transparent",
color: active ? "#ff8a7a" : "#a0a0a8",
fontFamily: mono,
fontSize: 11,
letterSpacing: ".10em",
textTransform: "uppercase",
fontWeight: active ? 600 : 400,
cursor: "pointer",
flex: "none",
whiteSpace: "nowrap",
}}
>
{t}
</button>
);
})}
</div>
{/* Sub-view for the active tab. */}
<div
style={{
display: "flex",
gap: 3,
marginTop: 6,
overflowX: "auto",
paddingBottom: 2,
scrollbarWidth: "thin",
}}
>
{(tab === "run"
? ([
["phases", mission.phases.length],
["tasks", mission.tasks.length],
["live", null],
] as Array<[string, number | null]>)
: tab === "output"
? ([
["documents", null],
["artifacts", mission.artifacts.length],
["benchmarks", mission.benchmarks.length],
] as Array<[string, number | null]>)
: ([
["overview", null],
["team", null],
...(mission.runtime_kind === "local_herdr"
? ([["pane", null]] as Array<[string, number | null]>)
: []),
] as Array<[string, number | null]>)
).map(([sub, badge]) => {
const current =
tab === "run" ? runSub : tab === "output" ? outputSub : setupSub;
const active = current === sub;
return (
<button
key={sub}
type="button"
onClick={() => {
if (tab === "run") setRunSub(sub as RunSub);
else if (tab === "output") setOutputSub(sub as OutputSub);
else setSetupSub(sub as SetupSub);
}}
style={{
padding: "3px 10px",
borderRadius: 999,
border: "1px solid transparent",
background: active ? "rgba(255,255,255,.07)" : "transparent",
color: active ? "#e0e0e5" : "#8a8a92",
fontFamily: mono,
fontSize: 10,
letterSpacing: ".08em",
textTransform: "uppercase",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 6,
flex: "none",
whiteSpace: "nowrap",
}}
>
{sub}
{badge !== null && (
<span style={{ fontSize: 9.5, color: "#6a6a72" }}>{badge}</span>
)}
</button>
);
})}
</div>
</div>
{/* The documents reader manages its own columns + scrolling, so it
renders full-bleed. Everything else lives in one padded scroller
— never a scroll container inside a scroll container. */}
{tab === "output" && outputSub === "documents" ? (
<MissionOutputReader
missionId={mission.id}
phases={mission.phases}
visible
/>
) : (
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 22 }}>
{tab === "setup" && setupSub === "overview" && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{mission.description && (
<div
style={{
padding: 14,
borderRadius: 10,
border: "1px solid rgba(255,255,255,.07)",
background: "#101014",
marginBottom: 4,
}}
>
<div
style={{
fontFamily: mono,
fontSize: 9.5,
letterSpacing: ".14em",
textTransform: "uppercase",
color: "#6a6a72",
marginBottom: 8,
}}
>
Brief
</div>
<MarkdownBlock source={mission.description} />
</div>
)}
<FieldRow k="Template" v={TEMPLATE_LABEL[mission.template_kind] ?? mission.template_kind} />
<FieldRow k="Status" v={mission.status} />
<FieldRow k="Team" v={mission.team_id ?? "(auto-provision on launch)"} />
<FieldRow k="Repo" v={mission.repo_id ?? "—"} />
<FieldRow
k="Schedule"
v={
mission.schedule.kind === "cron"
? `cron: ${mission.schedule.cron ?? "?"}`
: mission.schedule.kind === "on_event"
? `on: ${mission.schedule.event ?? "?"}`
: "one-shot"
}
/>
<FieldRow k="Created" v={new Date(mission.created_at).toLocaleString()} />
{mission.completed_at && (
<FieldRow k="Completed" v={new Date(mission.completed_at).toLocaleString()} />
)}
</div>
)}
{tab === "run" && runSub === "phases" && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{orderedPhases.length === 0 ? (
<Empty label="no phases" />
) : (
orderedPhases.map((p) => (
<div
key={p.id}
style={{
padding: 12,
borderRadius: 10,
border: "1px solid rgba(255,255,255,.07)",
background: "#101014",
display: "flex",
flexDirection: "column",
gap: 4,
minWidth: 0,
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flexWrap: "wrap",
minWidth: 0,
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: PHASE_STATUS_COLOR[p.status],
flex: "none",
}}
/>
<span
style={{
fontFamily: mono,
fontSize: 9,
color: PHASE_STATUS_COLOR[p.status],
textTransform: "uppercase",
}}
>
{p.status}
</span>
<span
style={{
fontSize: 13.5,
fontWeight: 700,
color: "#f3f3f5",
marginLeft: 8,
}}
>
#{p.order_idx + 1} · {PHASE_LABEL[p.kind] ?? p.kind}
</span>
</div>
{(p.started_at || p.completed_at) && (
<span style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>
{p.started_at ? `started ${new Date(p.started_at).toLocaleTimeString()}` : "—"}
{p.completed_at
? ` · finished ${new Date(p.completed_at).toLocaleTimeString()}`
: ""}
</span>
)}
{/* Renders only when the phase carries a done_when. */}
<PhaseGoalStrip missionId={mission.id} phase={p} />
<PhaseRunsList runs={runsByPhase.get(p.id) ?? []} />
{(p.status === "completed" || p.status === "failed") && (
<PhaseSummaryCard missionId={mission.id} phaseId={p.id} />
)}
{mission.status === "running" || mission.status === "completed" ? (
<div
style={{
display: "flex",
gap: 6,
marginTop: 6,
flexWrap: "wrap",
}}
>
{p.status === "failed" && mission.status === "running" && (
<button
type="button"
onClick={async () => {
if (phaseBusy === `retry:${p.id}`) return;
setPhaseBusy(`retry:${p.id}`);
setError(null);
try {
await retryMissionPhase(mission.id, p.id);
await load();
} catch (e) {
setError(
e instanceof Error ? e.message : "retry failed",
);
} finally {
setPhaseBusy(null);
}
}}
disabled={phaseBusy === `retry:${p.id}`}
title="Reset this phase to pending; prior failed runs are purged. phase_runner picks it up in ≤10s."
style={{
...secondaryBtn,
borderColor: "rgba(255,138,122,.45)",
color: "#ff8a7a",
background: "rgba(255,138,122,.08)",
opacity: phaseBusy === `retry:${p.id}` ? 0.5 : 1,
}}
>
{phaseBusy === `retry:${p.id}` ? "Retrying…" : "Retry"}
</button>
)}
{p.kind === "security_scan" && (
<button
type="button"
onClick={() => runSecurityScan(p.id)}
disabled={phaseBusy === `sec:${p.id}`}
style={{
...secondaryBtn,
opacity: phaseBusy === `sec:${p.id}` ? 0.5 : 1,
}}
>
{phaseBusy === `sec:${p.id}` ? "Scanning…" : "Run scan"}
</button>
)}
{p.kind === "benchmark" && (
<>
<button
type="button"
onClick={() => runBenchmark(p.id, "baseline")}
disabled={phaseBusy === `bench:${p.id}:baseline`}
style={{
...secondaryBtn,
opacity:
phaseBusy === `bench:${p.id}:baseline` ? 0.5 : 1,
}}
>
{phaseBusy === `bench:${p.id}:baseline`
? "Running…"
: "Baseline"}
</button>
<button
type="button"
onClick={() => runBenchmark(p.id, "after")}
disabled={phaseBusy === `bench:${p.id}:after`}
style={{
...secondaryBtn,
opacity:
phaseBusy === `bench:${p.id}:after` ? 0.5 : 1,
}}
>
{phaseBusy === `bench:${p.id}:after` ? "Running…" : "After"}
</button>
</>
)}
</div>
) : null}
</div>
))
)}
</div>
)}
{tab === "run" && runSub === "tasks" && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{mission.tasks.length === 0 ? (
<Empty label="no tasks yet — task-card parser lands in Slice 5" />
) : (
mission.tasks.map((t) => (
<div
key={t.id}
style={{
padding: 11,
borderRadius: 10,
border: "1px solid rgba(255,255,255,.07)",
background: "#101014",
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: TASK_STATUS_COLOR[t.status],
}}
/>
<span
style={{
fontFamily: mono,
fontSize: 9,
color: TASK_STATUS_COLOR[t.status],
textTransform: "uppercase",
}}
>
{t.status}
</span>
{t.external_id && (
<span
style={{
fontFamily: mono,
fontSize: 10.5,
color: "#7cd6e0",
}}
>
{t.external_id}
</span>
)}
<span
style={{
fontSize: 13,
color: "#f3f3f5",
marginLeft: 4,
}}
>
{t.title}
</span>
</div>
{t.artifact_paths.length > 0 && (
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#8a8a92" }}>
{t.artifact_paths.join(" · ")}
</div>
)}
</div>
))
)}
</div>
)}
{tab === "setup" && setupSub === "team" && (
<MissionTeamTab
missionId={mission.id}
teamId={mission.team_id}
onOpenClaw={onOpenClaw}
/>
)}
{tab === "run" && runSub === "live" && (
<MissionLiveEvents missionId={mission.id} visible={tab === "run" && runSub === "live"} />
)}
{tab === "output" && outputSub === "artifacts" && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{mission.artifacts.length === 0 ? (
<Empty label="no artifacts yet — phases produce them as they run" />
) : (
mission.artifacts.map((a) => {
const isPreviewing = pdfPreviewId === a.id;
return (
<div
key={a.id}
style={{ display: "flex", flexDirection: "column", gap: 8 }}
>
<div
style={{
padding: 11,
borderRadius: 10,
border: "1px solid rgba(255,255,255,.07)",
background: "#101014",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<FileText size={16} style={{ color: "#7cd6e0", flex: "none" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: "#f3f3f5", fontWeight: 500 }}>
{a.title ?? a.path.split("/").pop() ?? a.path}
</div>
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#8a8a92" }}>
{a.kind} · {a.path}
</div>
</div>
{a.rendered_pdf_path ? (
<>
<button
type="button"
onClick={() =>
setPdfPreviewId(isPreviewing ? null : a.id)
}
style={{
...secondaryBtn,
padding: "5px 10px",
fontSize: 11,
}}
>
{isPreviewing ? "Hide" : "Preview"}
</button>
<a
href={a.rendered_pdf_path}
target="_blank"
rel="noreferrer"
style={{
...secondaryBtn,
padding: "5px 10px",
fontSize: 11,
textDecoration: "none",
}}
>
Open
</a>
</>
) : a.render_pdf_status !== "skip" ? (
<span
style={{
fontFamily: mono,
fontSize: 10.5,
color:
a.render_pdf_status === "failed"
? "#ff8a7a"
: "#8a8a92",
}}
>
pdf: {a.render_pdf_status}
</span>
) : null}
</div>
{isPreviewing && a.rendered_pdf_path && (
<iframe
src={a.rendered_pdf_path}
title={a.title ?? a.path}
style={{
width: "100%",
height: 640,
border: "1px solid rgba(255,255,255,.06)",
borderRadius: 8,
background: "#0a0a0d",
}}
/>
)}
</div>
);
})
)}
</div>
)}
{tab === "output" && outputSub === "benchmarks" && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{mission.benchmarks.length === 0 ? (
<Empty label="no benchmark snapshots yet — trigger a baseline via /api/missions/{id}/benchmark or a workflow with benchmark = { mode = &quot;before_after&quot; }" />
) : (
mission.benchmarks.map((s) => {
const delta = s.delta as
| { kind?: string; samples?: Array<Record<string, unknown>> }
| null
| undefined;
return (
<div
key={s.id}
style={{
padding: 12,
borderRadius: 10,
border: "1px solid rgba(255,255,255,.07)",
background: "#101014",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
fontFamily: mono,
fontSize: 10.5,
color: s.iteration === 0 ? "#7cd6e0" : "#5fd08a",
letterSpacing: ".08em",
textTransform: "uppercase",
}}
>
{s.iteration === 0 ? "baseline" : `iter ${s.iteration}`}
</span>
{s.driver && (
<span
style={{
fontFamily: mono,
fontSize: 10,
color: "#8a8a92",
marginLeft: 4,
}}
>
{s.driver}
</span>
)}
<span
style={{
marginLeft: "auto",
fontFamily: mono,
fontSize: 10.5,
color: "#6a6a72",
}}
>
{new Date(s.created_at).toLocaleString()}
</span>
</div>
{delta?.samples && delta.samples.length > 0 && (
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 100px 100px 100px",
gap: 6,
fontSize: 12,
alignItems: "center",
}}
>
<span style={{ color: "#8a8a92" }}>bench</span>
<span style={{ color: "#8a8a92", textAlign: "right" }}>before</span>
<span style={{ color: "#8a8a92", textAlign: "right" }}>after</span>
<span style={{ color: "#8a8a92", textAlign: "right" }}>Δ%</span>
{(delta.samples as Array<Record<string, unknown>>).map((row, i) => {
const pct = Number(row["delta_pct"] ?? 0);
const dir = String(row["direction"] ?? "");
const color = dir === "improved" ? "#5fd08a" : "#ff8a7a";
return (
<React.Fragment key={i}>
<span
style={{
fontFamily: mono,
fontSize: 11.5,
color: "#d7d7db",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{String(row["name"])}
</span>
<span
style={{
fontFamily: mono,
fontSize: 11.5,
color: "#a0a0a8",
textAlign: "right",
}}
>
{String(row["before_ns"])} ns
</span>
<span
style={{
fontFamily: mono,
fontSize: 11.5,
color: "#a0a0a8",
textAlign: "right",
}}
>
{String(row["after_ns"])} ns
</span>
<span
style={{
fontFamily: mono,
fontSize: 11.5,
color,
textAlign: "right",
}}
>
{pct > 0 ? "+" : ""}
{pct.toFixed(1)}%
</span>
</React.Fragment>
);
})}
</div>
)}
</div>
);
})
)}
</div>
)}
{tab === "setup" && setupSub === "pane" && mission.runtime_kind === "local_herdr" && (
<MissionLivePane
nodeId={mission.target_node_id}
visible={tab === "setup" && setupSub === "pane"}
/>
)}
</div>
)}
</div>
);
}
function FieldRow({ k, v }: { k: string; v: string }) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: "140px 1fr",
gap: 12,
padding: "10px 12px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.06)",
background: "#101014",
}}
>
<span
style={{
fontFamily: mono,
fontSize: 10.5,
letterSpacing: ".1em",
color: "#8a8a92",
textTransform: "uppercase",
}}
>
{k}
</span>
<span style={{ fontSize: 12.5, color: "#eaeaee", wordBreak: "break-word" }}>
{v}
</span>
</div>
);
}
function Empty({ label }: { label: string }) {
return (
<div
style={{
padding: 24,
textAlign: "center",
color: "#6a6a72",
fontSize: 12.5,
fontFamily: mono,
}}
>
{label}
</div>
);
}
const iconBtn: React.CSSProperties = {
width: 30,
height: 30,
borderRadius: 8,
border: "1px solid rgba(255,255,255,.12)",
background: "transparent",
color: "#9a9aa2",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
};
const primaryBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "6px 12px",
borderRadius: 8,
border: 0,
background: "#ff8a7a",
color: "#1a0d0b",
fontSize: 12,
fontWeight: 700,
cursor: "pointer",
};
const secondaryBtn: React.CSSProperties = {
padding: "6px 12px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#d7d7db",
fontSize: 12,
cursor: "pointer",
};