"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 = { 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", 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"); 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 [pdfPreviewId, setPdfPreviewId] = 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); // Persist the collapsed state across mission switches so the operator // can keep the header hidden once they've read it. const [headerCollapsed, setHeaderCollapsed] = useState(() => { 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(); 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 (
{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 ( ); })()}

{mission.title}

{mission.description && ( )}
{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.
)} {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" ? ( ) : (
{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") && ( )} {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" && ( )} {tab === "run" && runSub === "live" && ( )} {tab === "output" && outputSub === "artifacts" && (
{mission.artifacts.length === 0 ? ( ) : ( mission.artifacts.map((a) => { const isPreviewing = pdfPreviewId === a.id; return (
{a.title ?? a.path.split("/").pop() ?? a.path}
{a.kind} · {a.path}
{a.rendered_pdf_path ? ( <> Open ) : a.render_pdf_status !== "skip" ? ( pdf: {a.render_pdf_status} ) : null}
{isPreviewing && a.rendered_pdf_path && (