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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user