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;
|
webhookUrl: string | null;
|
||||||
buildProg: { pct: number; label: string } | null;
|
buildProg: { pct: number; label: string } | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
/** User-picked topology from the gallery (Team / Swarm modes). */
|
||||||
|
topologyKind: string | null;
|
||||||
};
|
};
|
||||||
const emptySlice = (m: Mode): ModeSlice => ({
|
const emptySlice = (m: Mode): ModeSlice => ({
|
||||||
messages: [{ role: "planner", content: INTRO[m] }],
|
messages: [{ role: "planner", content: INTRO[m] }],
|
||||||
@@ -79,8 +81,16 @@ const emptySlice = (m: Mode): ModeSlice => ({
|
|||||||
webhookUrl: null,
|
webhookUrl: null,
|
||||||
buildProg: null,
|
buildProg: null,
|
||||||
error: 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 }) {
|
export function MasterPlannerModal({ onClose }: { onClose: () => void }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [mode, setMode] = useState<Mode>("specialists");
|
const [mode, setMode] = useState<Mode>("specialists");
|
||||||
@@ -97,11 +107,32 @@ export function MasterPlannerModal({ onClose }: { onClose: () => void }) {
|
|||||||
const [runSteps, setRunSteps] = useState<Step[]>([]);
|
const [runSteps, setRunSteps] = useState<Step[]>([]);
|
||||||
const [runStatus, setRunStatus] = useState<string | null>(null);
|
const [runStatus, setRunStatus] = useState<string | null>(null);
|
||||||
const [runFinal, setRunFinal] = 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 scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const esRef = useRef<EventSource | null>(null);
|
const esRef = useRef<EventSource | null>(null);
|
||||||
const snapshotsRef = useRef<Partial<Record<Mode, ModeSlice>>>({});
|
const snapshotsRef = useRef<Partial<Record<Mode, ModeSlice>>>({});
|
||||||
const modeRef = useRef<Mode>("specialists");
|
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(() => { 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(() => { scrollRef.current?.scrollTo({ top: 1e9, behavior: "smooth" }); }, [messages, thinking]);
|
||||||
useEffect(() => () => { esRef.current?.close(); }, []);
|
useEffect(() => () => { esRef.current?.close(); }, []);
|
||||||
@@ -111,7 +142,7 @@ export function MasterPlannerModal({ onClose }: { onClose: () => void }) {
|
|||||||
esRef.current?.close();
|
esRef.current?.close();
|
||||||
// Snapshot the current mode before we blow it away.
|
// Snapshot the current mode before we blow it away.
|
||||||
snapshotsRef.current[mode] = {
|
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);
|
const next = snapshotsRef.current[m] ?? emptySlice(m);
|
||||||
setMode(m);
|
setMode(m);
|
||||||
@@ -126,6 +157,7 @@ export function MasterPlannerModal({ onClose }: { onClose: () => void }) {
|
|||||||
setWebhookUrl(next.webhookUrl);
|
setWebhookUrl(next.webhookUrl);
|
||||||
setBuildProg(next.buildProg);
|
setBuildProg(next.buildProg);
|
||||||
setError(next.error);
|
setError(next.error);
|
||||||
|
setTopologyKind(next.topologyKind);
|
||||||
setThinking(false);
|
setThinking(false);
|
||||||
setBuilding(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>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{(mode === "team" || mode === "swarm") ? (
|
||||||
|
<TopologyStrip
|
||||||
|
catalog={catalog}
|
||||||
|
selected={topologyKind}
|
||||||
|
onSelect={setTopologyKind}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div style={{ flex: 1, minHeight: 0, display: "flex" }}>
|
<div style={{ flex: 1, minHeight: 0, display: "flex" }}>
|
||||||
{/* Chat */}
|
{/* Chat */}
|
||||||
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", borderRight: hasRightPanel ? "1px solid rgba(255,255,255,.07)" : undefined }}>
|
<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 {
|
function statusColor(s: string): string {
|
||||||
const t = s.toLowerCase();
|
const t = s.toLowerCase();
|
||||||
if (t === "ok" || t === "completed" || t === "done") return "#7fd0a0";
|
if (t === "ok" || t === "completed" || t === "done") return "#7fd0a0";
|
||||||
|
|||||||
Reference in New Issue
Block a user