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,470 @@
"use client";
// MissionWizard — unified 5-step wizard replacing ResearchWizard +
// LoopsWizard.
// 1. Template — pick one of the 5 workflow templates (or custom)
// 2. Target — repo picker (when template requires) + subject/prompt
// 3. Team — bind existing team (later slices: team template + auto-provision)
// 4. Schedule — one-shot / cron / on-event
// 5. Review — plan preview + launch
//
// Slice 2 ships a minimal implementation that creates a mission with
// the template's canned phase composition; Slice 4 threads the TOML
// recipe engine through here for real template dispatch.
import { useMemo, useState } from "react";
import { X } from "lucide-react";
import {
createMission,
presetForKind,
TEMPLATE_PRESETS,
type Schedule,
type TemplateKind,
type TemplatePreset,
} from "@/lib/api/missions";
import { RepoPicker, type PickedRepo } from "./RepoPicker";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
type Step = 1 | 2 | 3 | 4 | 5;
export function MissionWizard({
onClose,
onCreated,
}: {
onClose: () => void;
onCreated: (id: string) => void;
}) {
const [step, setStep] = useState<Step>(1);
const [templateKind, setTemplateKind] = useState<TemplateKind>("research_only");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [repo, setRepo] = useState<PickedRepo | null>(null);
const [teamId, setTeamId] = useState<string>("");
const [scheduleKind, setScheduleKind] = useState<"one_shot" | "cron">("one_shot");
const [cron, setCron] = useState("0 */6 * * *");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const preset: TemplatePreset = useMemo(
() => presetForKind(templateKind) ?? TEMPLATE_PRESETS[0],
[templateKind],
);
const canNext =
(step === 1 && !!templateKind) ||
(step === 2 &&
title.trim().length > 0 &&
(!preset.requiresRepo || repo !== null)) ||
step === 3 ||
step === 4;
async function submit() {
setError(null);
setSubmitting(true);
try {
const schedule: Schedule =
scheduleKind === "cron" ? { kind: "cron", cron } : { kind: "one_shot" };
const created = await createMission({
title: title.trim(),
template_kind: templateKind,
repo_id: repo?.repo_id,
team_id: teamId || undefined,
schedule,
description: description.trim() || undefined,
phases: preset.phases,
});
onCreated(created.id);
} catch (e) {
setError(e instanceof Error ? e.message : "create failed");
} finally {
setSubmitting(false);
}
}
return (
<div
onClick={onClose}
role="presentation"
style={{
position: "fixed",
inset: 0,
zIndex: 110,
background: "rgba(0,0,0,.62)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
animation: "cm-fade .18s ease",
}}
>
<div
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="New mission"
style={{
width: "100%",
maxWidth: 720,
maxHeight: "88vh",
display: "flex",
flexDirection: "column",
borderRadius: 16,
background: "#0d0d10",
border: "1px solid rgba(255,255,255,.1)",
boxShadow: "0 30px 90px rgba(0,0,0,.6)",
overflow: "hidden",
animation: "scale-in .18s ease",
}}
>
<div
style={{
flex: "none",
display: "flex",
alignItems: "center",
gap: 11,
padding: "16px 20px",
borderBottom: "1px solid rgba(255,255,255,.07)",
}}
>
<span
style={{
fontFamily: mono,
fontSize: 10,
letterSpacing: ".14em",
color: "#5ec8d8",
}}
>
STEP {step} / 5
</span>
<span style={{ fontSize: 15, fontWeight: 700, color: "#f3f3f5" }}>
New mission
</span>
<button
type="button"
onClick={onClose}
aria-label="Close"
style={{
marginLeft: "auto",
width: 28,
height: 28,
borderRadius: 8,
border: "1px solid rgba(255,255,255,.12)",
background: "transparent",
color: "#9a9aa2",
cursor: "pointer",
}}
>
<X aria-hidden size={14} />
</button>
</div>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 20 }}>
{step === 1 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Template</span>
<p style={hintStyle}>
Pick the workflow shape. This decides which phases run and what
the coding/research agents are asked to do.
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr",
gap: 8,
marginTop: 4,
}}
>
{TEMPLATE_PRESETS.map((p) => {
const active = p.kind === templateKind;
return (
<button
key={p.kind}
type="button"
onClick={() => setTemplateKind(p.kind)}
style={{
textAlign: "left",
padding: 12,
borderRadius: 10,
border: `1px solid ${active ? "rgba(255,138,122,.6)" : "rgba(255,255,255,.1)"}`,
background: active
? "rgba(255,138,122,.08)"
: "#101014",
cursor: "pointer",
color: "#eaeaee",
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<span
style={{
fontWeight: 700,
fontSize: 13.5,
color: "#f3f3f5",
}}
>
{p.title}
</span>
<span style={{ fontSize: 12, color: "#a0a0a8", lineHeight: 1.5 }}>
{p.blurb}
</span>
<span
style={{
fontFamily: mono,
fontSize: 10,
color: "#7cd6e0",
marginTop: 4,
}}
>
{p.phases.map((ph) => ph.kind).join(" → ")}
{p.requiresRepo ? " · needs repo" : ""}
</span>
</button>
);
})}
</div>
</div>
)}
{step === 2 && (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<label style={labelStyle} htmlFor="mission-title">
Title
</label>
<input
id="mission-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. ClawHDF5 spec — memory engine v3"
style={fieldStyle}
/>
<label style={labelStyle} htmlFor="mission-desc">
Description / prompt
</label>
<textarea
id="mission-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={5}
placeholder="What should the mission accomplish? The template's agents will use this as their driving prompt."
style={{ ...fieldStyle, resize: "vertical", fontFamily: "inherit" }}
/>
{preset.requiresRepo && (
<>
<span style={labelStyle}>Repository</span>
<p style={hintStyle}>
{preset.title} needs a repo bound so agents can read + write
/workspace/repo.
</p>
<RepoPicker value={repo} onChange={setRepo} />
</>
)}
</div>
)}
{step === 3 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Team</span>
<p style={hintStyle}>
Slice 3 ships team templates + auto-provision. For now, paste an
existing team id or leave blank to let phase execution
auto-provision one from the description.
</p>
<input
value={teamId}
onChange={(e) => setTeamId(e.target.value)}
placeholder="optional team UUID"
style={fieldStyle}
/>
</div>
)}
{step === 4 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Schedule</span>
<label style={radioRowStyle(scheduleKind === "one_shot")}>
<input
type="radio"
checked={scheduleKind === "one_shot"}
onChange={() => setScheduleKind("one_shot")}
/>
<div>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>One-shot</div>
<div style={hintStyle}>Runs once on launch. Simplest.</div>
</div>
</label>
<label style={radioRowStyle(scheduleKind === "cron")}>
<input
type="radio"
checked={scheduleKind === "cron"}
onChange={() => setScheduleKind("cron")}
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>Cron</div>
<div style={hintStyle}>
Fires on schedule. Coding phases re-check the repo and pick
up where they left off.
</div>
{scheduleKind === "cron" && (
<input
type="text"
value={cron}
onChange={(e) => setCron(e.target.value)}
placeholder="0 */6 * * *"
style={{ ...fieldStyle, marginTop: 6 }}
/>
)}
</div>
</label>
</div>
)}
{step === 5 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Review</span>
<ReviewRow k="Template" v={preset.title} />
<ReviewRow k="Title" v={title} />
{description && <ReviewRow k="Description" v={description} />}
{repo && <ReviewRow k="Repo" v={`${repo.owner}/${repo.name}`} />}
{teamId && <ReviewRow k="Team" v={teamId} />}
<ReviewRow
k="Schedule"
v={scheduleKind === "cron" ? `cron: ${cron}` : "one-shot"}
/>
<ReviewRow k="Phases" v={preset.phases.map((p) => p.kind).join(" → ")} />
{error && (
<p style={{ ...hintStyle, color: "#ff8a7a" }}>{error}</p>
)}
</div>
)}
</div>
<div
style={{
padding: 14,
borderTop: "1px solid rgba(255,255,255,.06)",
display: "flex",
gap: 8,
justifyContent: "space-between",
}}
>
<button
type="button"
onClick={() => setStep((s) => Math.max(1, s - 1) as Step)}
disabled={step === 1}
style={{ ...secondaryBtn, opacity: step === 1 ? 0.4 : 1 }}
>
Back
</button>
{step < 5 ? (
<button
type="button"
onClick={() => setStep((s) => Math.min(5, s + 1) as Step)}
disabled={!canNext}
style={{ ...primaryBtn, opacity: !canNext ? 0.4 : 1 }}
>
Next
</button>
) : (
<button
type="button"
onClick={submit}
disabled={submitting}
style={{ ...primaryBtn, opacity: submitting ? 0.5 : 1 }}
>
{submitting ? "Creating…" : "Launch mission"}
</button>
)}
</div>
</div>
</div>
);
}
function ReviewRow({ k, v }: { k: string; v: string }) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: "120px 1fr",
gap: 12,
padding: "8px 10px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.06)",
background: "#101014",
}}
>
<span
style={{
fontFamily: mono,
fontSize: 11,
color: "#8a8a92",
textTransform: "uppercase",
letterSpacing: ".08em",
}}
>
{k}
</span>
<span style={{ fontSize: 12.5, color: "#eaeaee", wordBreak: "break-word" }}>
{v}
</span>
</div>
);
}
const labelStyle: React.CSSProperties = {
fontFamily: mono,
fontSize: 10,
letterSpacing: ".14em",
color: "#8a8a92",
textTransform: "uppercase",
};
const hintStyle: React.CSSProperties = {
fontSize: 12,
color: "#a0a0a8",
lineHeight: 1.55,
};
const fieldStyle: React.CSSProperties = {
width: "100%",
boxSizing: "border-box",
padding: "8px 10px",
borderRadius: 9,
border: "1px solid rgba(255,255,255,.12)",
background: "#141417",
color: "#eaeaee",
fontSize: 13,
};
const primaryBtn: React.CSSProperties = {
padding: "8px 14px",
borderRadius: 9,
border: 0,
background: "#ff8a7a",
color: "#1a0d0b",
fontSize: 12.5,
fontWeight: 700,
cursor: "pointer",
};
const secondaryBtn: React.CSSProperties = {
padding: "8px 14px",
borderRadius: 9,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#d7d7db",
fontSize: 12.5,
cursor: "pointer",
};
const radioRowStyle = (active: boolean): React.CSSProperties => ({
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "10px 12px",
borderRadius: 10,
border: `1px solid ${active ? "rgba(94,200,216,.4)" : "rgba(255,255,255,.08)"}`,
background: active ? "rgba(94,200,216,.05)" : "#101014",
cursor: "pointer",
});