diff --git a/web/src/lib/stores/chat.ts b/web/src/lib/stores/chat.ts index 23ec6c2..a56afd1 100644 --- a/web/src/lib/stores/chat.ts +++ b/web/src/lib/stores/chat.ts @@ -71,7 +71,18 @@ export const sessions = writable([]) export const sessionMessages = writable([]) export const error = writable(null) -let activeController: AbortController | null = null +// Per-session controller tracking. Multiple tasks can stream concurrently +// (see sendMessage's session guard above this used to be a single global +// `activeController`, which meant cancelStream()/newChat() always aborted +// whichever stream happened to be the MOST RECENTLY started one, regardless +// of what the operator was currently viewing — starting Task A, switching to +// (already-loaded) Task B, then clicking "New task" would silently abort +// Task A's still-running turn even though the operator was never looking at +// it and never asked to cancel it. Keyed by session id once known; +// pendingController covers the brief window for a brand-new task between +// streamChat() starting and its 'session' event assigning a real id. +const activeControllers = new Map() +let pendingController: AbortController | null = null export async function loadSessions() { const list = await fetchSessions() @@ -207,12 +218,21 @@ export function sendMessage(text: string) { const openedFor = get(currentSession) let streamSessionID = openedFor - activeController = streamChat( + const controller = streamChat( text, get(currentSession), // continue the active session so the agent keeps context (ev: ChatEvent) => { if (ev.type === 'session') { streamSessionID = ev.data + // Move this stream's controller into the per-session map now that its + // real id is known, so a later cancelStream()/newChat() from THIS + // session's view can find and abort it — and, just as importantly, + // so cancelling/leaving a DIFFERENT session never reaches this one. + // For a continued (non-new) session, openedFor already equals ev.data + // and the controller was stored under that key at creation below; + // this only does real work for a brand-new task's first assignment. + if (pendingController === controller) pendingController = null + activeControllers.set(ev.data, controller) // Only claim currentSession if the operator hasn't already navigated // to something else since this call started (openedFor covers both // "still on the task I was on" and "still hadn't opened one yet"). @@ -298,10 +318,26 @@ export function sendMessage(text: string) { }, () => { if (get(currentSession) === streamSessionID) streaming.set(false) - activeController = null + // Clean up whichever slot this controller ended up in — normally + // activeControllers[streamSessionID] once the 'session' event has + // fired, but fall back to pendingController for the (rare) case where + // the stream errored/completed before ever getting one. + if (streamSessionID && activeControllers.get(streamSessionID) === controller) { + activeControllers.delete(streamSessionID) + } + if (pendingController === controller) pendingController = null loadSessions() } ) + + // Register immediately (not just inside the 'session' handler above) so a + // cancelStream() during the brief pre-'session' window for a CONTINUED + // session (openedFor already known) can find it right away. + if (openedFor) { + activeControllers.set(openedFor, controller) + } else { + pendingController = controller + } } export function newChat() { @@ -310,15 +346,27 @@ export function newChat() { currentSession.set(null) messages.set([]) error.set(null) - streaming.set(false) // fresh view — see loadSessionMessages for why this must not depend on cancelStream's own (activeController-only) reset + streaming.set(false) // fresh view — see loadSessionMessages for why this must not depend on cancelStream's own reset } +// Cancels the stream for whatever the operator is CURRENTLY VIEWING — never +// some other, unrelated task's background stream. Before per-session +// tracking, this aborted a single global `activeController`, which meant it +// always targeted the MOST RECENTLY STARTED stream regardless of what was on +// screen: start Task A, switch to already-loaded Task B, click "New task" — +// newChat()'s cancelStream() would silently abort Task A's still-running +// turn, even though the operator was never looking at it and never asked to +// cancel it. Now it looks up by $currentSession (or pendingController for +// the brief pre-'session'-event window of a just-started new task) so it can +// only ever touch the stream that belongs to the view being left. export function cancelStream() { - if (activeController) { - activeController.abort() - activeController = null - streaming.set(false) - } + const sid = get(currentSession) + const controller = sid ? activeControllers.get(sid) : pendingController + if (!controller) return + controller.abort() + if (sid) activeControllers.delete(sid) + if (pendingController === controller) pendingController = null + streaming.set(false) } export async function deleteSession(sessionId: string) {