slice 2: MissionWizard + MissionCanvas + MissionsList frontend
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:
co-authored by
Claude Opus 4.7
parent
a72da9dff0
commit
fc67936e33
@@ -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",
|
||||
};
|
||||
Reference in New Issue
Block a user