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