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,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",
|
||||
});
|
||||
Reference in New Issue
Block a user