master planner: preserve state across mode switches

switchMode was resetting messages/proposal/swarm/runSteps/etc. every
time. Now it snapshots the current mode's slice into a ref before
loading the target mode's snapshot (or a fresh slice if the target
was never visited). Switching specialists → swarm → specialists keeps
the specialists chat intact.

Also route async /planner/chat responses to the mode the message was
sent in, not the currently-active mode — user can switch modes while
the reply is in flight without the assistant response landing in the
wrong chat.
This commit is contained in:
Omar Sobh
2026-07-07 04:16:36 -07:00
parent 18a99a970d
commit 9056937434
@@ -51,10 +51,38 @@ async function readSse(url: string, body: unknown, onEvt: (e: Record<string, unk
}
}
// Per-mode chat/proposal state so switching modes preserves what was there.
// Kept in a ref (not state) — restore reads it during switchMode and immediately
// pushes into the individual setters below, so React re-renders once.
type ModeSlice = {
messages: Msg[];
input: string;
proposal: Proposal | null;
swarm: SwarmSpec | null;
runSteps: Step[];
runStatus: string | null;
runFinal: string | null;
webhookUrl: string | null;
buildProg: { pct: number; label: string } | null;
error: string | null;
};
const emptySlice = (m: Mode): ModeSlice => ({
messages: [{ role: "planner", content: INTRO[m] }],
input: "",
proposal: null,
swarm: null,
runSteps: [],
runStatus: null,
runFinal: null,
webhookUrl: null,
buildProg: null,
error: null,
});
export function MasterPlannerModal({ onClose }: { onClose: () => void }) {
const router = useRouter();
const [mode, setMode] = useState<Mode>("specialists");
const [messages, setMessages] = useState<Msg[]>([{ role: "planner", content: INTRO.specialists }]);
const [messages, setMessages] = useState<Msg[]>(() => emptySlice("specialists").messages);
const [input, setInput] = useState("");
const [thinking, setThinking] = useState(false);
const [proposal, setProposal] = useState<Proposal | null>(null);
@@ -69,6 +97,8 @@ export function MasterPlannerModal({ onClose }: { onClose: () => void }) {
const [runFinal, setRunFinal] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const esRef = useRef<EventSource | null>(null);
const snapshotsRef = useRef<Partial<Record<Mode, ModeSlice>>>({});
const modeRef = useRef<Mode>("specialists");
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]);
@@ -77,23 +107,64 @@ export function MasterPlannerModal({ onClose }: { onClose: () => void }) {
function switchMode(m: Mode) {
if (m === mode) return;
esRef.current?.close();
setMode(m); setMessages([{ role: "planner", content: INTRO[m] }]); setInput("");
setProposal(null); setSwarm(null); setError(null); setBuildProg(null); setWebhookUrl(null);
setRunSteps([]); setRunStatus(null); setRunFinal(null);
// Snapshot the current mode before we blow it away.
snapshotsRef.current[mode] = {
messages, input, proposal, swarm, runSteps, runStatus, runFinal, webhookUrl, buildProg, error,
};
const next = snapshotsRef.current[m] ?? emptySlice(m);
setMode(m);
modeRef.current = m;
setMessages(next.messages);
setInput(next.input);
setProposal(next.proposal);
setSwarm(next.swarm);
setRunSteps(next.runSteps);
setRunStatus(next.runStatus);
setRunFinal(next.runFinal);
setWebhookUrl(next.webhookUrl);
setBuildProg(next.buildProg);
setError(next.error);
setThinking(false);
setBuilding(false);
}
async function send() {
const text = input.trim();
if (!text || thinking) return;
const originMode = mode;
const next: Msg[] = [...messages, { role: "user", content: text }];
setMessages(next); setInput(""); setThinking(true); setError(null);
// Route async writes to the mode the message was sent in — user may switch
// mid-flight; without this the reply lands in the wrong chat.
const routeMessages = (updater: (prev: Msg[]) => Msg[]) => {
if (modeRef.current === originMode) {
setMessages(updater);
} else {
const snap = snapshotsRef.current[originMode] ?? emptySlice(originMode);
snapshotsRef.current[originMode] = { ...snap, messages: updater(snap.messages) };
}
};
const routeProposal = (p: Proposal) => {
if (modeRef.current === originMode) setProposal(p);
else {
const snap = snapshotsRef.current[originMode] ?? emptySlice(originMode);
snapshotsRef.current[originMode] = { ...snap, proposal: p };
}
};
const routeSwarm = (sp: SwarmSpec) => {
if (modeRef.current === originMode) setSwarm(sp);
else {
const snap = snapshotsRef.current[originMode] ?? emptySlice(originMode);
snapshotsRef.current[originMode] = { ...snap, swarm: sp };
}
};
try {
await readSse("/api/planner/chat", { mode, messages: next.map((m) => ({ role: m.role === "user" ? "user" : "assistant", content: m.content })) }, (e) => {
await readSse("/api/planner/chat", { mode: originMode, messages: next.map((m) => ({ role: m.role === "user" ? "user" : "assistant", content: m.content })) }, (e) => {
if (e.stage === "error") setError(String(e.label || "error"));
else if (e.stage === "done") {
if (e.reply) setMessages((m) => [...m, { role: "planner", content: String(e.reply) }]);
if (e.proposal) setProposal(e.proposal as Proposal);
if (e.swarm) setSwarm(e.swarm as SwarmSpec);
if (e.reply) routeMessages((m) => [...m, { role: "planner", content: String(e.reply) }]);
if (e.proposal) routeProposal(e.proposal as Proposal);
if (e.swarm) routeSwarm(e.swarm as SwarmSpec);
}
});
} catch { setError("Network error"); }