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]>
1342 lines
46 KiB
TypeScript
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 = "before_after" }" />
|
|
) : (
|
|
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",
|
|
};
|