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 }) {
|
export function MasterPlannerModal({ onClose }: { onClose: () => void }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [mode, setMode] = useState<Mode>("specialists");
|
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 [input, setInput] = useState("");
|
||||||
const [thinking, setThinking] = useState(false);
|
const [thinking, setThinking] = useState(false);
|
||||||
const [proposal, setProposal] = useState<Proposal | null>(null);
|
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 [runFinal, setRunFinal] = useState<string | null>(null);
|
||||||
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 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(() => { 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]);
|
||||||
@@ -77,23 +107,64 @@ export function MasterPlannerModal({ onClose }: { onClose: () => void }) {
|
|||||||
function switchMode(m: Mode) {
|
function switchMode(m: Mode) {
|
||||||
if (m === mode) return;
|
if (m === mode) return;
|
||||||
esRef.current?.close();
|
esRef.current?.close();
|
||||||
setMode(m); setMessages([{ role: "planner", content: INTRO[m] }]); setInput("");
|
// Snapshot the current mode before we blow it away.
|
||||||
setProposal(null); setSwarm(null); setError(null); setBuildProg(null); setWebhookUrl(null);
|
snapshotsRef.current[mode] = {
|
||||||
setRunSteps([]); setRunStatus(null); setRunFinal(null);
|
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() {
|
async function send() {
|
||||||
const text = input.trim();
|
const text = input.trim();
|
||||||
if (!text || thinking) return;
|
if (!text || thinking) return;
|
||||||
|
const originMode = mode;
|
||||||
const next: Msg[] = [...messages, { role: "user", content: text }];
|
const next: Msg[] = [...messages, { role: "user", content: text }];
|
||||||
setMessages(next); setInput(""); setThinking(true); setError(null);
|
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 {
|
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"));
|
if (e.stage === "error") setError(String(e.label || "error"));
|
||||||
else if (e.stage === "done") {
|
else if (e.stage === "done") {
|
||||||
if (e.reply) setMessages((m) => [...m, { role: "planner", content: String(e.reply) }]);
|
if (e.reply) routeMessages((m) => [...m, { role: "planner", content: String(e.reply) }]);
|
||||||
if (e.proposal) setProposal(e.proposal as Proposal);
|
if (e.proposal) routeProposal(e.proposal as Proposal);
|
||||||
if (e.swarm) setSwarm(e.swarm as SwarmSpec);
|
if (e.swarm) routeSwarm(e.swarm as SwarmSpec);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch { setError("Network error"); }
|
} catch { setError("Network error"); }
|
||||||
|
|||||||
Reference in New Issue
Block a user