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
@@ -27,6 +27,8 @@ import { ResearchList } from "./ResearchList";
|
|||||||
import { ResearchCanvas } from "./ResearchCanvas";
|
import { ResearchCanvas } from "./ResearchCanvas";
|
||||||
import { LoopsList } from "./LoopsList";
|
import { LoopsList } from "./LoopsList";
|
||||||
import { LoopsCanvas } from "./LoopsCanvas";
|
import { LoopsCanvas } from "./LoopsCanvas";
|
||||||
|
import { MissionsList } from "./MissionsList";
|
||||||
|
import { MissionCanvas } from "./MissionCanvas";
|
||||||
import { RepoList } from "./RepoList";
|
import { RepoList } from "./RepoList";
|
||||||
import { RepoCanvas } from "./RepoCanvas";
|
import { RepoCanvas } from "./RepoCanvas";
|
||||||
import { RepoConnectionWizardStub } from "./RepoConnectionWizardStub";
|
import { RepoConnectionWizardStub } from "./RepoConnectionWizardStub";
|
||||||
@@ -67,7 +69,7 @@ function readSidebarCollapse(): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tier = "world" | "research" | "loops" | "claw" | "repos" | "infra";
|
type Tier = "world" | "research" | "loops" | "missions" | "claw" | "repos" | "infra";
|
||||||
const mono = "'JetBrains Mono', ui-monospace, monospace";
|
const mono = "'JetBrains Mono', ui-monospace, monospace";
|
||||||
|
|
||||||
// dashboard-data.ts fabricates a few org/company/team nodes so an empty
|
// dashboard-data.ts fabricates a few org/company/team nodes so an empty
|
||||||
@@ -117,6 +119,8 @@ const railIcon: Record<Tier, React.ReactNode> = {
|
|||||||
research: (<svg width="20" height="20" viewBox="0 0 24 24"><rect x="4" y="3.5" width="12" height="14" rx="1.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><path d="M4 15.5 H16" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="17.5" cy="17.5" r="3.2" stroke="currentColor" strokeWidth="1.6" fill="none" /><path d="M19.9 19.9 L22 22" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg>),
|
research: (<svg width="20" height="20" viewBox="0 0 24 24"><rect x="4" y="3.5" width="12" height="14" rx="1.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><path d="M4 15.5 H16" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="17.5" cy="17.5" r="3.2" stroke="currentColor" strokeWidth="1.6" fill="none" /><path d="M19.9 19.9 L22 22" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg>),
|
||||||
// Circular arrow ⟳ with an inner dot — loops.
|
// Circular arrow ⟳ with an inner dot — loops.
|
||||||
loops: (<svg width="20" height="20" viewBox="0 0 24 24"><path d="M20 12 A8 8 0 1 1 12 4" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" /><path d="M13 3 L20 4 L18.5 10" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinejoin="round" strokeLinecap="round" /><circle cx="12" cy="12" r="1.8" fill="currentColor" /></svg>),
|
loops: (<svg width="20" height="20" viewBox="0 0 24 24"><path d="M20 12 A8 8 0 1 1 12 4" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" /><path d="M13 3 L20 4 L18.5 10" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinejoin="round" strokeLinecap="round" /><circle cx="12" cy="12" r="1.8" fill="currentColor" /></svg>),
|
||||||
|
// Flag on a pole — missions.
|
||||||
|
missions: (<svg width="20" height="20" viewBox="0 0 24 24"><path d="M6 3 V21" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /><path d="M6 4 L18 4 L15 8 L18 12 L6 12 Z" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinejoin="round" /></svg>),
|
||||||
claw: (<svg width="20" height="20" viewBox="0 0 20 20"><rect x="4" y="4" width="12" height="12" rx="3.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="10" cy="10" r="2.4" fill="currentColor" /></svg>),
|
claw: (<svg width="20" height="20" viewBox="0 0 20 20"><rect x="4" y="4" width="12" height="12" rx="3.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="10" cy="10" r="2.4" fill="currentColor" /></svg>),
|
||||||
// Branching lines forking off a trunk — repositories.
|
// Branching lines forking off a trunk — repositories.
|
||||||
repos: (<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="5" cy="5" r="1.6" fill="currentColor" /><circle cx="5" cy="15" r="1.6" fill="currentColor" /><circle cx="15" cy="10" r="1.6" fill="currentColor" /><path d="M5 6.6 V13.4 M5 10 C5 10 8 10 15 10" stroke="currentColor" strokeWidth="1.4" fill="none" /></svg>),
|
repos: (<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="5" cy="5" r="1.6" fill="currentColor" /><circle cx="5" cy="15" r="1.6" fill="currentColor" /><circle cx="15" cy="10" r="1.6" fill="currentColor" /><path d="M5 6.6 V13.4 M5 10 C5 10 8 10 15 10" stroke="currentColor" strokeWidth="1.4" fill="none" /></svg>),
|
||||||
@@ -538,9 +542,12 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
const isWorld = tier === "world",
|
const isWorld = tier === "world",
|
||||||
isResearch = tier === "research",
|
isResearch = tier === "research",
|
||||||
isLoops = tier === "loops",
|
isLoops = tier === "loops",
|
||||||
|
isMissions = tier === "missions",
|
||||||
isClaw = tier === "claw",
|
isClaw = tier === "claw",
|
||||||
isRepos = tier === "repos",
|
isRepos = tier === "repos",
|
||||||
isInfra = tier === "infra";
|
isInfra = tier === "infra";
|
||||||
|
const [missionsSel, setMissionsSel] = useState<string | null>(null);
|
||||||
|
const [missionsRefresh, setMissionsRefresh] = useState(0);
|
||||||
|
|
||||||
const allAgents = orgs.flatMap((o) => o.companies.flatMap((c) => c.teams.flatMap((t) => t.agents)));
|
const allAgents = orgs.flatMap((o) => o.companies.flatMap((c) => c.teams.flatMap((t) => t.agents)));
|
||||||
// World: the full expandable org→company→team→agent forest. Agents page: a flat list.
|
// World: the full expandable org→company→team→agent forest. Agents page: a flat list.
|
||||||
@@ -634,6 +641,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
<span style={crumbStyle(isResearch)} onClick={() => setTier("research")}>Research</span>
|
<span style={crumbStyle(isResearch)} onClick={() => setTier("research")}>Research</span>
|
||||||
<span style={{ color: "#3a3a40" }}>/</span>
|
<span style={{ color: "#3a3a40" }}>/</span>
|
||||||
<span style={crumbStyle(isLoops)} onClick={() => setTier("loops")}>Loops</span>
|
<span style={crumbStyle(isLoops)} onClick={() => setTier("loops")}>Loops</span>
|
||||||
|
<span style={crumbStyle(isMissions)} onClick={() => setTier("missions")}>Missions</span>
|
||||||
<span style={{ color: "#3a3a40" }}>/</span>
|
<span style={{ color: "#3a3a40" }}>/</span>
|
||||||
<span style={crumbStyle(isClaw)} onClick={() => setTier("claw")}>Agents</span>
|
<span style={crumbStyle(isClaw)} onClick={() => setTier("claw")}>Agents</span>
|
||||||
<span style={{ color: "#3a3a40" }}>/</span>
|
<span style={{ color: "#3a3a40" }}>/</span>
|
||||||
@@ -706,7 +714,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={() => { setSelectMode((v) => { if (v) setSelectedAgents(new Set()); return !v; }); }} title="Select to manage" aria-label="Select to manage" style={{ flex: "none", width: 34, height: 34, borderRadius: 9, border: `1px solid ${selectMode ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.12)"}`, background: selectMode ? "rgba(255,111,97,.12)" : "transparent", color: selectMode ? "#ff6f61" : "#9a9aa2", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}><Wrench aria-hidden size={16} /></button>
|
<button type="button" onClick={() => { setSelectMode((v) => { if (v) setSelectedAgents(new Set()); return !v; }); }} title="Select to manage" aria-label="Select to manage" style={{ flex: "none", width: 34, height: 34, borderRadius: 9, border: `1px solid ${selectMode ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.12)"}`, background: selectMode ? "rgba(255,111,97,.12)" : "transparent", color: selectMode ? "#ff6f61" : "#9a9aa2", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}><Wrench aria-hidden size={16} /></button>
|
||||||
</div>
|
</div>
|
||||||
) : isInfra || isResearch || isLoops || isRepos ? null : (
|
) : isInfra || isResearch || isLoops || isMissions || isRepos ? null : (
|
||||||
<div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)", display: "flex", alignItems: "center", gap: 8 }}>
|
<div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)", display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
{/* Title on the left; toolbar (wrench / history / brain) sits
|
{/* Title on the left; toolbar (wrench / history / brain) sits
|
||||||
flush right at the same baseline. The prior "N orgs · N co
|
flush right at the same baseline. The prior "N orgs · N co
|
||||||
@@ -746,6 +754,16 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
setLoopsRefresh((n) => n + 1);
|
setLoopsRefresh((n) => n + 1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : isMissions ? (
|
||||||
|
<MissionsList
|
||||||
|
selectedId={missionsSel}
|
||||||
|
onSelect={setMissionsSel}
|
||||||
|
refreshKey={missionsRefresh}
|
||||||
|
onCreated={(id) => {
|
||||||
|
setMissionsSel(id);
|
||||||
|
setMissionsRefresh((n) => n + 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : isRepos ? (
|
) : isRepos ? (
|
||||||
<RepoList
|
<RepoList
|
||||||
selectedId={repoSel}
|
selectedId={repoSel}
|
||||||
@@ -842,6 +860,12 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
setLoopsRefresh((n) => n + 1);
|
setLoopsRefresh((n) => n + 1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : isMissions ? (
|
||||||
|
<MissionCanvas
|
||||||
|
selectedId={missionsSel}
|
||||||
|
refreshKey={missionsRefresh}
|
||||||
|
onChanged={() => setMissionsRefresh((n) => n + 1)}
|
||||||
|
/>
|
||||||
) : isRepos ? (
|
) : isRepos ? (
|
||||||
<RepoCanvas selectedId={repoSel} refreshKey={repoRefresh} />
|
<RepoCanvas selectedId={repoSel} refreshKey={repoRefresh} />
|
||||||
) : isWorld ? (
|
) : isWorld ? (
|
||||||
|
|||||||
@@ -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",
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Sidebar for the Missions tier — the unified list that replaces
|
||||||
|
// ResearchList + LoopsList after Slice 9's cutover. During Slice 2 it
|
||||||
|
// runs in parallel with the old two lists so we can iterate on the
|
||||||
|
// UX without breaking existing workflows.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Plus, RotateCw } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
listMissions,
|
||||||
|
type Mission,
|
||||||
|
type MissionStatus,
|
||||||
|
type TemplateKind,
|
||||||
|
} from "@/lib/api/missions";
|
||||||
|
import { MissionWizard } from "./MissionWizard";
|
||||||
|
|
||||||
|
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 TEMPLATE_BADGE: Record<TemplateKind, { label: string; color: string }> = {
|
||||||
|
research_only: { label: "RESEARCH", color: "#7cd6e0" },
|
||||||
|
research_and_code: { label: "R+CODE", color: "#c98af0" },
|
||||||
|
security_hardening: { label: "SECURITY", color: "#ff8a7a" },
|
||||||
|
refactor: { label: "REFACTOR", color: "#ffb44a" },
|
||||||
|
benchmark: { label: "BENCHMARK", color: "#83e6a5" },
|
||||||
|
custom: { label: "CUSTOM", color: "#8a8a92" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MissionsList({
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
refreshKey,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
refreshKey: number;
|
||||||
|
onCreated: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const [missions, setMissions] = useState<Mission[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const rows = await listMissions(100);
|
||||||
|
setMissions(rows);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "load failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
(async () => {
|
||||||
|
if (alive) await load();
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [load, refreshKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: "none",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "12px 14px 8px",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: ".16em",
|
||||||
|
color: "#8a8a92",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
MISSIONS · {missions.length}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={load}
|
||||||
|
title="Refresh"
|
||||||
|
aria-label="Refresh"
|
||||||
|
style={iconBtn}
|
||||||
|
>
|
||||||
|
<RotateCw size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWizardOpen(true)}
|
||||||
|
title="New mission"
|
||||||
|
aria-label="New mission"
|
||||||
|
style={{
|
||||||
|
...iconBtn,
|
||||||
|
color: "#ff8a7a",
|
||||||
|
borderColor: "rgba(255,138,122,.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: "4px 10px 12px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 11, color: "#5ec8d8", padding: 8 }}>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{ fontSize: 12, color: "#ff8a7a", padding: 8 }}>{error}</div>
|
||||||
|
) : missions.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 10,
|
||||||
|
padding: 12,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12.5, color: "#cfcfd5", lineHeight: 1.5 }}>
|
||||||
|
No missions yet. Pick a workflow template to get started.
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWizardOpen(true)}
|
||||||
|
style={ctaBtn}
|
||||||
|
>
|
||||||
|
<Plus size={13} /> New mission
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
missions.map((m) => {
|
||||||
|
const active = selectedId === m.id;
|
||||||
|
const badge = TEMPLATE_BADGE[m.template_kind] ?? TEMPLATE_BADGE.custom;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(m.id)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 4,
|
||||||
|
padding: "10px 11px",
|
||||||
|
borderRadius: 9,
|
||||||
|
border: `1px solid ${active ? "rgba(94,200,216,.5)" : "rgba(255,255,255,.07)"}`,
|
||||||
|
background: active ? "rgba(94,200,216,.08)" : "#101013",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#eaeaee",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: STATUS_COLOR[m.status],
|
||||||
|
flex: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 9,
|
||||||
|
color: STATUS_COLOR[m.status],
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.status}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: "auto",
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 9,
|
||||||
|
color: badge.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#f3f3f5",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.title}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{wizardOpen && (
|
||||||
|
<MissionWizard
|
||||||
|
onClose={() => setWizardOpen(false)}
|
||||||
|
onCreated={(id) => {
|
||||||
|
setWizardOpen(false);
|
||||||
|
onCreated(id);
|
||||||
|
void load();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconBtn: React.CSSProperties = {
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
borderRadius: 7,
|
||||||
|
border: "1px solid rgba(255,255,255,.12)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#9a9aa2",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctaBtn: React.CSSProperties = {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 9,
|
||||||
|
border: 0,
|
||||||
|
background: "#ff8a7a",
|
||||||
|
color: "#1a0d0b",
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
// Missions API client — the unified workflow surface.
|
||||||
|
// Slice 2: create / list / get / status. Later slices layer
|
||||||
|
// template-driven behavior + task/artifact/benchmark rendering.
|
||||||
|
|
||||||
|
export type TemplateKind =
|
||||||
|
| "research_only"
|
||||||
|
| "research_and_code"
|
||||||
|
| "security_hardening"
|
||||||
|
| "refactor"
|
||||||
|
| "benchmark"
|
||||||
|
| "custom";
|
||||||
|
|
||||||
|
export type MissionStatus =
|
||||||
|
| "draft"
|
||||||
|
| "running"
|
||||||
|
| "completed"
|
||||||
|
| "failed"
|
||||||
|
| "cancelled";
|
||||||
|
|
||||||
|
export type PhaseKind = "research" | "coding" | "benchmark" | "security_scan";
|
||||||
|
|
||||||
|
export type PhaseStatus =
|
||||||
|
| "pending"
|
||||||
|
| "running"
|
||||||
|
| "completed"
|
||||||
|
| "failed"
|
||||||
|
| "skipped";
|
||||||
|
|
||||||
|
export type TaskStatus =
|
||||||
|
| "created"
|
||||||
|
| "working"
|
||||||
|
| "validating"
|
||||||
|
| "complete"
|
||||||
|
| "failed";
|
||||||
|
|
||||||
|
export type ArtifactKind =
|
||||||
|
| "md"
|
||||||
|
| "pdf"
|
||||||
|
| "benchmark_result"
|
||||||
|
| "security_report"
|
||||||
|
| "code_diff"
|
||||||
|
| "index";
|
||||||
|
|
||||||
|
export type ScheduleKind = "one_shot" | "cron" | "on_event";
|
||||||
|
export interface Schedule {
|
||||||
|
kind: ScheduleKind;
|
||||||
|
cron?: string;
|
||||||
|
event?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Mission {
|
||||||
|
id: string;
|
||||||
|
workspace_id: string;
|
||||||
|
title: string;
|
||||||
|
template_kind: TemplateKind;
|
||||||
|
team_id: string | null;
|
||||||
|
team_template_id: string | null;
|
||||||
|
repo_id: string | null;
|
||||||
|
schedule: Schedule;
|
||||||
|
status: MissionStatus;
|
||||||
|
description: string | null;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MissionPhase {
|
||||||
|
id: string;
|
||||||
|
mission_id: string;
|
||||||
|
kind: PhaseKind;
|
||||||
|
order_idx: number;
|
||||||
|
status: PhaseStatus;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
started_at: string | null;
|
||||||
|
completed_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MissionTask {
|
||||||
|
id: string;
|
||||||
|
mission_id: string;
|
||||||
|
phase_id: string;
|
||||||
|
external_id: string | null;
|
||||||
|
title: string;
|
||||||
|
assigned_agent_id: string | null;
|
||||||
|
status: TaskStatus;
|
||||||
|
run_id: string | null;
|
||||||
|
artifact_paths: string[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MissionArtifact {
|
||||||
|
id: string;
|
||||||
|
mission_id: string;
|
||||||
|
phase_id: string | null;
|
||||||
|
path: string;
|
||||||
|
kind: ArtifactKind;
|
||||||
|
mime: string | null;
|
||||||
|
generated_by_run: string | null;
|
||||||
|
rendered_pdf_path: string | null;
|
||||||
|
render_pdf_status: "skip" | "pending" | "rendering" | "done" | "failed";
|
||||||
|
render_pdf_error: string | null;
|
||||||
|
title: string | null;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MissionDetail extends Mission {
|
||||||
|
phases: MissionPhase[];
|
||||||
|
tasks: MissionTask[];
|
||||||
|
artifacts: MissionArtifact[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhaseSpec {
|
||||||
|
kind: PhaseKind;
|
||||||
|
order_idx: number;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMissionRequest {
|
||||||
|
title: string;
|
||||||
|
template_kind: TemplateKind;
|
||||||
|
team_id?: string;
|
||||||
|
team_template_id?: string;
|
||||||
|
repo_id?: string;
|
||||||
|
schedule?: Schedule;
|
||||||
|
description?: string;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
phases?: PhaseSpec[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const r = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const text = await r.text().catch(() => "");
|
||||||
|
throw new Error(`${init?.method ?? "GET"} ${path} → ${r.status} ${text}`);
|
||||||
|
}
|
||||||
|
if (r.status === 204) return undefined as T;
|
||||||
|
return (await r.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listMissions = (limit = 50) =>
|
||||||
|
api<Mission[]>(`/api/missions?limit=${limit}`);
|
||||||
|
|
||||||
|
export const getMission = (id: string) =>
|
||||||
|
api<MissionDetail>(`/api/missions/${id}`);
|
||||||
|
|
||||||
|
export const createMission = (body: CreateMissionRequest) =>
|
||||||
|
api<Mission>("/api/missions", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setMissionStatus = (id: string, status: MissionStatus) =>
|
||||||
|
api<Mission>(`/api/missions/${id}/status`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Template presets ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Slice 2 ships hardcoded phase compositions per template_kind so
|
||||||
|
// the wizard can create sensible missions before Slice 4's TOML
|
||||||
|
// recipe engine lands. When Slice 4 arrives, the server derives
|
||||||
|
// phases from the recipe and this preset table becomes a fallback
|
||||||
|
// for offline / preview rendering.
|
||||||
|
|
||||||
|
export interface TemplatePreset {
|
||||||
|
kind: TemplateKind;
|
||||||
|
title: string;
|
||||||
|
blurb: string;
|
||||||
|
requiresRepo: boolean;
|
||||||
|
phases: PhaseSpec[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TEMPLATE_PRESETS: TemplatePreset[] = [
|
||||||
|
{
|
||||||
|
kind: "research_only",
|
||||||
|
title: "Research only",
|
||||||
|
blurb:
|
||||||
|
"Produce a styled MD + PDF artifact in the workspace. One-shot or scheduled.",
|
||||||
|
requiresRepo: false,
|
||||||
|
phases: [{ kind: "research", order_idx: 0 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "research_and_code",
|
||||||
|
title: "Research + Coding Loop",
|
||||||
|
blurb:
|
||||||
|
"Research a topic against a repo, then loop the coding team through the produced INT-XX items until done.",
|
||||||
|
requiresRepo: true,
|
||||||
|
phases: [
|
||||||
|
{ kind: "research", order_idx: 0 },
|
||||||
|
{ kind: "coding", order_idx: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "security_hardening",
|
||||||
|
title: "Security Hardening",
|
||||||
|
blurb:
|
||||||
|
"Scan the repo for vulnerabilities, research patches, then apply + verify.",
|
||||||
|
requiresRepo: true,
|
||||||
|
phases: [
|
||||||
|
{ kind: "security_scan", order_idx: 0 },
|
||||||
|
{ kind: "research", order_idx: 1 },
|
||||||
|
{ kind: "coding", order_idx: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "refactor",
|
||||||
|
title: "Refactor",
|
||||||
|
blurb:
|
||||||
|
"Audit dependencies + versions, propose API/SDK adaptations, apply the changes.",
|
||||||
|
requiresRepo: true,
|
||||||
|
phases: [{ kind: "coding", order_idx: 0 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "benchmark",
|
||||||
|
title: "Benchmark",
|
||||||
|
blurb:
|
||||||
|
"Author + baseline benchmarks so subsequent refactors can be measured before/after.",
|
||||||
|
requiresRepo: true,
|
||||||
|
phases: [{ kind: "benchmark", order_idx: 0 }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const presetForKind = (k: TemplateKind): TemplatePreset | undefined =>
|
||||||
|
TEMPLATE_PRESETS.find((p) => p.kind === k);
|
||||||
Reference in New Issue
Block a user