master planner: topology gallery strip for Team + Swarm modes

New horizontally-scrollable card strip appears between the mode selector
and the split pane when the mode is 'team' or 'swarm'. Cards are fetched
from GET /api/topologies (the full 12-kind catalog), rendered as pill
cards with name + description. Clicking selects; 'planner picks' clears.
Selection persists per-mode via the ModeSlice snapshot, so switching
modes doesn't forget the choice. Scaffold wire-through comes next
(task #9).
This commit is contained in:
Omar Sobh
2026-07-07 04:20:12 -07:00
parent 987f4f0e84
commit ab4a62ef7d
@@ -67,6 +67,8 @@ type ModeSlice = {
webhookUrl: string | null;
buildProg: { pct: number; label: string } | null;
error: string | null;
/** User-picked topology from the gallery (Team / Swarm modes). */
topologyKind: string | null;
};
const emptySlice = (m: Mode): ModeSlice => ({
messages: [{ role: "planner", content: INTRO[m] }],
@@ -79,8 +81,16 @@ const emptySlice = (m: Mode): ModeSlice => ({
webhookUrl: null,
buildProg: null,
error: null,
topologyKind: null,
});
type CatalogEntry = {
kind: string;
name: string;
description: string;
role_distribution: { role: string; weight: number }[];
};
export function MasterPlannerModal({ onClose }: { onClose: () => void }) {
const router = useRouter();
const [mode, setMode] = useState<Mode>("specialists");
@@ -97,11 +107,32 @@ export function MasterPlannerModal({ onClose }: { onClose: () => void }) {
const [runSteps, setRunSteps] = useState<Step[]>([]);
const [runStatus, setRunStatus] = useState<string | null>(null);
const [runFinal, setRunFinal] = useState<string | null>(null);
const [topologyKind, setTopologyKind] = useState<string | null>(null);
const [catalog, setCatalog] = useState<CatalogEntry[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);
const esRef = useRef<EventSource | null>(null);
const snapshotsRef = useRef<Partial<Record<Mode, ModeSlice>>>({});
const modeRef = useRef<Mode>("specialists");
// Load the topology catalog once — the strip for team/swarm renders from it.
useEffect(() => {
let cancelled = false;
async function load() {
try {
const r = await fetch("/api/topologies");
if (!r.ok) return;
const c = (await r.json()) as CatalogEntry[];
if (!cancelled) setCatalog(c);
} catch {
// Non-fatal — gallery falls back to a hint.
}
}
void load();
return () => {
cancelled = true;
};
}, []);
useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]);
useEffect(() => { scrollRef.current?.scrollTo({ top: 1e9, behavior: "smooth" }); }, [messages, thinking]);
useEffect(() => () => { esRef.current?.close(); }, []);
@@ -111,7 +142,7 @@ export function MasterPlannerModal({ onClose }: { onClose: () => void }) {
esRef.current?.close();
// Snapshot the current mode before we blow it away.
snapshotsRef.current[mode] = {
messages, input, proposal, swarm, runSteps, runStatus, runFinal, webhookUrl, buildProg, error,
messages, input, proposal, swarm, runSteps, runStatus, runFinal, webhookUrl, buildProg, error, topologyKind,
};
const next = snapshotsRef.current[m] ?? emptySlice(m);
setMode(m);
@@ -126,6 +157,7 @@ export function MasterPlannerModal({ onClose }: { onClose: () => void }) {
setWebhookUrl(next.webhookUrl);
setBuildProg(next.buildProg);
setError(next.error);
setTopologyKind(next.topologyKind);
setThinking(false);
setBuilding(false);
}
@@ -240,6 +272,14 @@ export function MasterPlannerModal({ onClose }: { onClose: () => void }) {
<button type="button" onClick={onClose} aria-label="Close" style={{ width: 28, height: 28, borderRadius: 8, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#9a9aa2", cursor: "pointer" }}></button>
</div>
{(mode === "team" || mode === "swarm") ? (
<TopologyStrip
catalog={catalog}
selected={topologyKind}
onSelect={setTopologyKind}
/>
) : null}
<div style={{ flex: 1, minHeight: 0, display: "flex" }}>
{/* Chat */}
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", borderRight: hasRightPanel ? "1px solid rgba(255,255,255,.07)" : undefined }}>
@@ -320,6 +360,131 @@ export function MasterPlannerModal({ onClose }: { onClose: () => void }) {
);
}
function TopologyStrip({
catalog,
selected,
onSelect,
}: {
catalog: CatalogEntry[];
selected: string | null;
onSelect: (kind: string | null) => void;
}) {
if (catalog.length === 0) {
return (
<div
style={{
flex: "none",
padding: "10px 16px",
borderBottom: "1px solid rgba(255,255,255,.07)",
fontFamily: mono,
fontSize: 11,
color: "#6a6a72",
}}
>
Loading topology catalog
</div>
);
}
return (
<div
style={{
flex: "none",
padding: "8px 12px 10px",
borderBottom: "1px solid rgba(255,255,255,.07)",
background: "rgba(255,255,255,.02)",
}}
>
<div
style={{
fontFamily: mono,
fontSize: 9.5,
letterSpacing: ".12em",
color: "#6a6a72",
padding: "0 4px 6px",
textTransform: "uppercase",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span>Topology · {catalog.length} available</span>
{selected ? (
<button
type="button"
onClick={() => onSelect(null)}
style={{
background: "transparent",
border: 0,
color: "#6a6a72",
fontFamily: mono,
fontSize: 9.5,
cursor: "pointer",
textDecoration: "underline",
}}
>
planner picks
</button>
) : (
<span style={{ color: "#5a5a62" }}>planner picks unless you choose</span>
)}
</div>
<div
style={{
display: "flex",
gap: 6,
overflowX: "auto",
padding: "2px 4px 4px",
scrollbarWidth: "thin",
}}
>
{catalog.map((c) => {
const active = selected === c.kind;
return (
<button
key={c.kind}
type="button"
onClick={() => onSelect(c.kind)}
title={c.description}
style={{
flex: "none",
minWidth: 132,
textAlign: "left",
padding: "7px 10px",
borderRadius: 8,
background: active
? "rgba(201,138,240,.12)"
: "rgba(255,255,255,.03)",
border: active
? "1px solid rgba(201,138,240,.5)"
: "1px solid rgba(255,255,255,.08)",
color: active ? "#f3f3f5" : "#cfcfd5",
fontFamily: "inherit",
cursor: "pointer",
transition: "background .12s ease",
}}
>
<div style={{ fontSize: 12, fontWeight: 700 }}>{c.name}</div>
<div
style={{
fontFamily: mono,
fontSize: 9.5,
color: active ? "#c98af0" : "#6a6a72",
marginTop: 2,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{c.description}
</div>
</button>
);
})}
</div>
</div>
);
}
function statusColor(s: string): string {
const t = s.toLowerCase();
if (t === "ok" || t === "completed" || t === "done") return "#7fd0a0";