slice 2: MissionWizard + MissionCanvas + MissionsList frontend
ci / gates (push) Successful in 4s
ci / frontend (push) Successful in 26s
ci / rust (push) Successful in 4m10s
ci / e2e (push) Skipped
ci / publish (push) Successful in 4m10s

Adds the unified missions tier to the dashboard. Runs in parallel
with Research + Loops tabs until Slice 9's big-bang cutover.

New files:
  - lib/api/missions.ts       — typed API client + 5 template presets
                                (research_only, research_and_code,
                                security_hardening, refactor, benchmark)
  - dashboard/MissionsList.tsx — sidebar list with status pills +
                                template-kind badges + new-mission CTA
  - dashboard/MissionWizard.tsx — 5-step adaptive wizard:
      1) template picker (5 cards)
      2) title/description + repo (when required)
      3) team (placeholder — Slice 3 wires templates + auto-provision)
      4) schedule (one-shot or cron)
      5) review + launch
  - dashboard/MissionCanvas.tsx — 4-tab detail view (overview/phases/
                                tasks/artifacts), draft→running launch

Dashboard.tsx gets a new "missions" tier + crumb + rail icon (flag).

Slice 2 ships a minimal implementation that creates missions with the
template's canned phase composition. Slice 4 replaces the preset table
with real TOML-recipe dispatch on the server; the client's fallback
presets keep offline preview working.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-19 12:19:19 -07:00
co-authored by Claude Opus 4.7
parent a72da9dff0
commit fc67936e33
5 changed files with 1587 additions and 2 deletions
@@ -0,0 +1,581 @@
"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 { useCallback, useEffect, useMemo, useState } from "react";
import { FileText, Play, RefreshCw } from "lucide-react";
import {
getMission,
setMissionStatus,
type MissionDetail,
type MissionStatus,
type PhaseKind,
type PhaseStatus,
type TaskStatus,
type TemplateKind,
} from "@/lib/api/missions";
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",
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",
};
type Tab = "overview" | "phases" | "tasks" | "artifacts";
export function MissionCanvas({
selectedId,
refreshKey,
onChanged,
}: {
selectedId: string | null;
refreshKey: number;
onChanged: () => 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>("overview");
const [launching, setLaunching] = useState(false);
const load = useCallback(async () => {
if (!selectedId) {
setMission(null);
return;
}
setLoading(true);
setError(null);
try {
const m = await getMission(selectedId);
setMission(m);
} 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]);
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]);
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={load}
title="Refresh"
aria-label="Refresh"
style={iconBtn}
>
<RefreshCw size={13} />
</button>
{mission.status === "draft" && (
<button
type="button"
onClick={launch}
disabled={launching}
style={{
...primaryBtn,
opacity: launching ? 0.5 : 1,
}}
>
<Play size={13} style={{ marginRight: 4 }} />
{launching ? "Launching…" : "Launch"}
</button>
)}
</div>
</div>
<h1 style={{ margin: 0, fontSize: 20, color: "#f3f3f5" }}>{mission.title}</h1>
{mission.description && (
<p style={{ margin: 0, fontSize: 13, color: "#cfcfd5", lineHeight: 1.5 }}>
{mission.description}
</p>
)}
<div style={{ display: "flex", gap: 4, marginTop: 4 }}>
{(["overview", "phases", "tasks", "artifacts"] as Tab[]).map((t) => {
const active = tab === t;
const badge =
t === "tasks"
? mission.tasks.length
: t === "artifacts"
? mission.artifacts.length
: t === "phases"
? mission.phases.length
: null;
return (
<button
key={t}
type="button"
onClick={() => setTab(t)}
style={{
padding: "5px 12px",
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: ".08em",
textTransform: "uppercase",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 6,
}}
>
{t}
{badge !== null && (
<span
style={{
fontSize: 10,
color: active ? "#ff8a7a" : "#8a8a92",
}}
>
{badge}
</span>
)}
</button>
);
})}
</div>
</div>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 22 }}>
{tab === "overview" && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<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 === "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,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: PHASE_STATUS_COLOR[p.status],
}}
/>
<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>
)}
</div>
))
)}
</div>
)}
{tab === "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 === "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) => (
<div
key={a.id}
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 ? (
<a
href={a.rendered_pdf_path}
target="_blank"
rel="noreferrer"
style={{
...secondaryBtn,
padding: "5px 10px",
fontSize: 11,
textDecoration: "none",
}}
>
Open PDF
</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>
))
)}
</div>
)}
</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",
};