diff --git a/web/src/lib/stores/chat.ts b/web/src/lib/stores/chat.ts index 5672674..23ec6c2 100644 --- a/web/src/lib/stores/chat.ts +++ b/web/src/lib/stores/chat.ts @@ -110,6 +110,15 @@ function toChatMessages(msgs: Message[]): ChatMessage[] { export async function loadSessionMessages(sessionId: string) { currentSession.set(sessionId) + // This is a fresh view of sessionId's current (REST-loaded) state — reset + // streaming regardless of whether some OTHER task's stream happens to still + // be in flight in the background. Without this, switching to a task while + // a different one is mid-turn could leave `streaming` stuck true here (that + // other stream's completion callback now correctly skips touching it, per + // sendMessage's session guard) — which would disable the input AND silently + // stop startPolling's loop from ever applying updates (it bails while + // $streaming is true), making the newly-opened task look frozen. + streaming.set(false) const msgs = await fetchMessages(sessionId) sessionMessages.set(msgs) messages.set(toChatMessages(msgs)) @@ -181,13 +190,37 @@ export function sendMessage(text: string) { let activeTools: Map = new Map() + // Multiple tasks can stream concurrently (the backend runs each turn as its + // own goroutine — nothing serializes them), but `messages`/`currentSession` + // are a single global view. Without this guard, switching to a different + // task while this stream is still open lets its later events (tool_use, + // text_delta, ..., and worst of all the 'done' handler's + // currentSession.set) get applied to whatever the operator is NOW looking + // at — silently corrupting another task's transcript, or yanking the view + // back to this one. openedFor is the session this call started for (null + // for a brand-new task, until the 'session' event assigns the real id); + // every branch below checks the CURRENT $currentSession still matches + // before touching `messages`. The task itself keeps running server-side + // regardless — dropped events just mean the live view isn't watching it; + // navigating back re-hydrates via REST/poll same as it already does for + // auto-continuation. + const openedFor = get(currentSession) + let streamSessionID = openedFor + activeController = streamChat( text, get(currentSession), // continue the active session so the agent keeps context (ev: ChatEvent) => { if (ev.type === 'session') { - currentSession.set(ev.data) - } else if (ev.type === 'tool_use') { + streamSessionID = ev.data + // 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"). + if (get(currentSession) === openedFor) currentSession.set(ev.data) + return + } + if (get(currentSession) !== streamSessionID) return // stream's task isn't the one on screen — drop + if (ev.type === 'tool_use') { const tr: ToolCallResult = { type: 'tool_use', name: ev.data.name, @@ -248,20 +281,23 @@ export function sendMessage(text: string) { return [...ms] }) const sid = ev.data?.session_id ?? ev.session_id - currentSession.set(sid) // Start polling for auto-continuation results now that the live turn // is over — this is what makes an approved plan's later steps show up - // on their own instead of requiring a manual reload. + // on their own instead of requiring a manual reload. (startPolling's + // own loop already re-checks $currentSession before applying results, + // so this is safe to call even if the operator has since navigated + // elsewhere — it just won't visibly do anything until/unless they + // come back.) if (sid) startPolling(sid) } else if (ev.type === 'error') { error.set(ev.data) } }, (err: string) => { - error.set(err) + if (get(currentSession) === streamSessionID) error.set(err) }, () => { - streaming.set(false) + if (get(currentSession) === streamSessionID) streaming.set(false) activeController = null loadSessions() } @@ -274,6 +310,7 @@ 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 } export function cancelStream() {