"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 { 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 { MissionTabScroller } from "./MissionTabScroller"; import { MissionArtifacts } from "./MissionArtifacts"; import { MissionLiveEvents } from "./MissionLiveEvents"; import { MissionLivePane } from "./MissionLivePane"; import { MissionOutputReader } from "./MissionOutputReader"; import { MissionTeamTab } from "./MissionTeamTab"; import { MissionProposalDrawer } from "./MissionProposalDrawer"; 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 = { draft: "#8a8a92", running: "#5ec8d8", completed: "#5fd08a", failed: "#ff8a7a", cancelled: "#6a6a72", }; const PHASE_STATUS_COLOR: Record = { 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 = { created: "#8a8a92", working: "#5ec8d8", validating: "#ffb44a", complete: "#5fd08a", failed: "#ff8a7a", }; const PHASE_LABEL: Record = { research: "Research", coding: "Coding", benchmark: "Benchmark", security_scan: "Security scan", }; const TEMPLATE_LABEL: Record = { research_only: "Research only", research_and_code: "Research + Coding Loop", security_hardening: "Security Hardening", refactor: "Refactor", benchmark: "Benchmark", continuous_research: "Continuous Research", self_audit: "Self-audit", 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(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [tab, setTab] = useState("run"); // The propose→review→approve gate. Backend has been complete since it // shipped; this is the first way for a human to reach the review step. const [proposalsOpen, setProposalsOpen] = useState(false); const [runSub, setRunSub] = useState("phases"); const [outputSub, setOutputSub] = useState("documents"); const [setupSub, setSetupSub] = useState("overview"); const [launching, setLaunching] = useState(false); const [refining, setRefining] = useState(false); const [refineDiff, setRefineDiff] = useState(null); const [accepting, setAccepting] = useState(false); const [phaseBusy, setPhaseBusy] = useState(null); const [wizardOpen, setWizardOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); const [deleteBusy, setDeleteBusy] = useState(false); const [runs, setRuns] = useState([]); const [lastLoadedAt, setLastLoadedAt] = useState(null); 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(); 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 (
Pick a mission from the left, or hit + to create one.
); } if (loading && !mission) { return (
Loading…
); } if (error) { return (
{error}
); } if (!mission) return null; return ( // absolute-inset, NOT flex:1 — the canvas host is a position:relative BLOCK, // so flex:1 is inert here and collapses this root (and its scroller) to zero.
{mission.status} {TEMPLATE_LABEL[mission.template_kind] ?? mission.template_kind}
{mission.status === "draft" && ( )} {mission.status === "draft" && ( )} {lastLoadedAt && ( updated {lastLoadedAt.toLocaleTimeString()} )} {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 }) ?.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 ( ); })()}
{/* Title only. The description peek that used to live here was ~92px of permanently-pinned chrome showing a masked, unreadable fragment of text that renders in full a click away in Setup → Overview. Deleting it gives that space to the results and removes one collapsible. */}

{mission.title}

{refineDiff && ( setRefineDiff(null)} onUndoAfterAccept={undoRefine} /> )} {wizardOpen && ( setWizardOpen(false)} onCreated={(id) => { setWizardOpen(false); onSelect?.(id); onChanged(); }} /> )} {editOpen && ( setEditOpen(false)} onSaved={async () => { setEditOpen(false); onChanged(); await load(); }} /> )} {/* Primary tabs — three, not eight. */}
{(["run", "output", "setup"] as Tab[]).map((t) => { const active = tab === t; return ( ); })}
{/* Sub-view for the active tab. */}
{(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 ( ); })}
{/* 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" ? ( ) : ( // `key` remounts the scroller per tab. Without it every tab shares one DOM // node, so scrollTop leaks: scroll to the bottom of a long run list, switch // to Setup, and you land mid-page in unrelated content. // `stick` only on the streaming views — see MissionTabScroller. {tab === "setup" && setupSub === "overview" && (
{mission.description && (
Brief
)} {mission.completed_at && ( )}
)} {tab === "run" && runSub === "phases" && (
{orderedPhases.length === 0 ? ( ) : ( orderedPhases.map((p) => (
{p.status} #{p.order_idx + 1} · {PHASE_LABEL[p.kind] ?? p.kind}
{(p.started_at || p.completed_at) && ( {p.started_at ? `started ${new Date(p.started_at).toLocaleTimeString()}` : "—"} {p.completed_at ? ` · finished ${new Date(p.completed_at).toLocaleTimeString()}` : ""} )} {/* Renders only when the phase carries a done_when. */} {(p.status === "completed" || p.status === "failed") && ( // Which summary is worth reading right now: while the // mission runs, the operator is watching the live phase, so // finished ones fold away. Once it is over, the LAST phase // holds the outcome. A failure always opens. )} {mission.status === "running" || mission.status === "completed" ? (
{p.status === "failed" && mission.status === "running" && ( )} {p.kind === "security_scan" && ( )} {p.kind === "benchmark" && ( <> )}
) : null}
)) )}
)} {tab === "run" && runSub === "tasks" && (
{mission.tasks.length === 0 ? ( ) : ( mission.tasks.map((t) => (
{t.status} {t.external_id && ( {t.external_id} )} {t.title}
{t.artifact_paths.length > 0 && (
{t.artifact_paths.join(" · ")}
)}
)) )}
)} {tab === "setup" && setupSub === "team" && (
{mission.status === "draft" ? "a proposed plan or roster can be approved while this mission is a draft" : `approval applies to drafts only — this mission is ${mission.status}`}
)} {tab === "run" && runSub === "live" && ( )} {tab === "output" && outputSub === "artifacts" && ( )} {tab === "output" && outputSub === "benchmarks" && (
{mission.benchmarks.length === 0 ? ( ) : ( mission.benchmarks.map((s) => { const delta = s.delta as | { kind?: string; samples?: Array> } | null | undefined; return (
{s.iteration === 0 ? "baseline" : `iter ${s.iteration}`} {s.driver && ( {s.driver} )} {new Date(s.created_at).toLocaleString()}
{delta?.samples && delta.samples.length > 0 && (
bench before after Δ% {(delta.samples as Array>).map((row, i) => { const pct = Number(row["delta_pct"] ?? 0); const dir = String(row["direction"] ?? ""); const color = dir === "improved" ? "#5fd08a" : "#ff8a7a"; return ( {String(row["name"])} {String(row["before_ns"])} ns {String(row["after_ns"])} ns {pct > 0 ? "+" : ""} {pct.toFixed(1)}% ); })}
)}
); }) )}
)} {tab === "setup" && setupSub === "pane" && mission.runtime_kind === "local_herdr" && ( )}
)} {proposalsOpen && ( setProposalsOpen(false)} onChanged={onChanged} /> )}
); } function FieldRow({ k, v }: { k: string; v: string }) { return (
{k} {v}
); } function Empty({ label }: { label: string }) { return (
{label}
); } 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", };