import { writable, get, type Writable } from 'svelte/store' import { streamChat, fetchSessions, fetchMessages, fetchMessagesOrNotFound, deleteSession as apiDeleteSession } from '$lib/api' import type { ChatEvent, Session, Message } from '$lib/api' import type { ToolCallResult } from '$lib/types' import { liveEvents, subscribeEvents } from './events' export type { ToolCallResult } export interface PendingApproval { executionId: string action: string target: string destructive: boolean command?: string purpose?: string } export interface ChatMessage { id: string role: 'user' | 'assistant' text: string thinking?: string tools: ToolCallResult[] pendingApprovals: PendingApproval[] created_at?: string } const APPROVAL_RE = /execution\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i // Deliberately NOT filtered by tool name. There is no fixed set of gated // tools — `run` can execute anything, and any future tool that queues an // approval should surface a card the same way. A prior version hardcoded // `t.name === 'request_execution'`, so approvals raised by the newer `run` // tool were silently invisible in chat: no card, no feedback, nothing to // self-heal, forcing the operator to the Ops page with zero acknowledgement // back in the conversation. Matching on the response shape (not the tool // name) is what makes this robust to new gated tools without another // silent breakage. function extractApprovals(tools: ToolCallResult[]): PendingApproval[] { const out: PendingApproval[] = [] for (const t of tools) { if (t.type !== 'tool_result') continue const text = typeof t.result === 'string' ? t.result : JSON.stringify(t.result ?? '') if (!text.includes('requires approval')) continue const m = text.match(APPROVAL_RE) if (m) { const args = t.args ?? {} const purpose = typeof args.purpose === 'string' ? args.purpose : undefined out.push({ executionId: m[1], action: purpose ? purpose.slice(0, 60) : typeof args.action === 'string' ? args.action : t.name, target: typeof args.target === 'string' ? args.target : 'unknown', destructive: /\bDESTRUCTIVE\b/.test(text), command: typeof args.command === 'string' ? args.command : undefined, purpose }) } } return out } function mid(): string { return crypto.randomUUID() } // dropOptimisticAssistantBubble removes the trailing empty assistant message // that sendSessionMessage/startTask optimistically append — used when a turn is // QUEUED behind an in-flight one (plan 2026-08-03 F2): no live assistant stream // is attached, so the empty placeholder must go (otherwise it lingers as a // blank bubble). Shared so the guard can't drift between the two call sites. function dropOptimisticAssistantBubble(messages: Writable): void { messages.update((ms) => { const last = ms[ms.length - 1] if (last && last.role === 'assistant' && last.text === '' && last.tools.length === 0) { return ms.slice(0, -1) } return ms }) } export const messages = writable([]) export const streaming = writable(false) export const connectionState = writable<'connected' | 'disconnected' | 'reconnecting'>('connected') export const currentSession = writable(null) export const sessions = writable([]) export const sessionMessages = writable([]) export const error = writable(null) export const chatErrors = writable<{ id: string; message: string; action?: string; tag?: string }[]>([]) export function dismissError(id: string) { chatErrors.update((e) => e.filter((x) => x.id !== id)) } export function addChatError(message: string, action?: string, tag?: string) { chatErrors.update((e) => [...e, { id: crypto.randomUUID(), message, action, tag }]) } // Session statuses where no turn is running — the agent reached a terminal // state (done/failed/abandoned) or paused for operator input (awaiting_input). // A task.status event landing in one of these is an authoritative "the turn // ended" signal, used by clearTurnState (F3) to unstick a chat view that lost // its SSE stream mid-turn. const TURN_ENDED_STATUS = new Set(['done', 'failed', 'abandoned', 'awaiting_input']) // humanizeChatError turns raw transport/SDK error strings into operator- // readable, non-alarming copy. The raw forms ("llm: error in input stream: // …", "Failed to fetch", "HTTP 502") read as catastrophic and unactionable; // most are transient model-connection drops where the task itself is fine. // Used for both the inline error box (LLM error events) and the connection // toast (F2). function humanizeChatError(raw: string): string { const s = raw.toLowerCase() if ( s.includes('input stream') || s.includes('llm:') || s.includes('failed to fetch') || s.includes('network') || s.includes('econnreset') || s.includes('timeout') || /http 5\d\d/.test(s) ) { return 'The model connection dropped. The task keeps running in the background — it will catch up here automatically.' } if (/http 401|http 403|unauthor|forbidden/.test(s)) { return 'Your session expired. Reconnect to continue.' } return raw } // clearTurnState resets a session's chat view to a clean "connected, idle" // state — the recovery action when a dropped SSE left it stuck showing // streaming/disconnected after the turn had already ended. Clears the window // bundle, the global bundle (if that's the viewed session), and any // connection-lost toasts tagged 'connection' (F2/F3). function clearTurnState(sessionId: string) { const win = sessionChats.get(sessionId) if (win) { win.streaming.set(false) win.connectionState.set('connected') } if (get(currentSession) === sessionId) { streaming.set(false) connectionState.set('connected') } chatErrors.update((errs) => errs.filter((e) => e.tag !== 'connection')) } // One app-lifetime subscription to the always-on event stream: a terminal // task.status for a session we have open is the authoritative end-of-turn // signal, and recovers a chat view whose SSE dropped without a 'done' event // (the "task never ended" symptom). Ref-counted by subscribeEvents, so this // shares the single connection the rest of the app already keeps open. // // Lazily armed from chatFor() (P2.2) rather than at module import, so // importing this module — e.g. in a test — doesn't open an SSE connection as // an import side-effect. let chatEventSyncArmed = false function ensureChatEventSync() { if (chatEventSyncArmed) return chatEventSyncArmed = true subscribeEvents() liveEvents.subscribe((events) => { const ev = events[0] if (!ev || ev.type !== 'task.status') return const sid = ev.correlation_id if (!sid) return const status = (ev.data as { status?: string } | null)?.status if (typeof status === 'string' && TURN_ENDED_STATUS.has(status)) { clearTurnState(sid) } }) } // 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() sessions.set(list) } // mergeToolCalls collapses a persisted tool_calls array into one entry per // call id. Nomos persists the tool_use and tool_result as two separate // entries sharing the same id (matching the SSE event pair); Chat.svelte // renders tools in a keyed {#each ... (tool.id)}, which throws on duplicate // keys and silently aborts the whole message list. Live-streamed messages // never hit this because sendMessage() merges tool_result into the existing // tool_use entry in place rather than appending a second one. function mergeToolCalls(raw: ToolCallResult[] | undefined): ToolCallResult[] { const byId = new Map() for (const tc of raw ?? []) { const key = tc.id ?? crypto.randomUUID() const existing = byId.get(key) byId.set(key, existing ? { ...existing, ...tc, id: key } : { ...tc, id: key }) } return Array.from(byId.values()) } function toChatMessages(msgs: Message[]): ChatMessage[] { return msgs.map((m) => { const content = typeof m.content === 'string' ? { text: m.content } : m.content const tools = mergeToolCalls(content?.tool_calls) return { id: m.id, role: m.role as 'user' | 'assistant', text: content?.text ?? '', thinking: content?.thinking ?? undefined, tools, pendingApprovals: extractApprovals(tools), created_at: m.created_at } }) } function chatMessagesChanged(a: ChatMessage[], b: ChatMessage[]): boolean { if (a.length !== b.length) return true for (let i = 0; i < a.length; i++) { if (a[i].id !== b[i].id || a[i].role !== b[i].role || a[i].text !== b[i].text || a[i].tools.length !== b[i].tools.length) { return true } } return false } 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)) startPolling(sessionId) } // Live visibility for autonomous work: the auto-continuation worker (see // cmd/nomos/continue.go) runs entirely server-side and has no live push — // previously the only way to see its result was to manually reload the // session, so approving a plan and then waiting felt like nothing was // happening even while the agent was actively working. This polls the // session's persisted messages every few seconds and merges in anything new // (an auto-continuation's result, a fresh pending approval it queued, etc.) // so the transcript updates on its own. Only runs between turns — never // while a live streaming turn owns the message list, to avoid clobbering the // in-progress optimistic UI. let pollTimer: ReturnType | null = null let pollingSessionId: string | null = null function startPolling(sessionId: string) { stopPolling() pollingSessionId = sessionId pollTimer = setInterval(async () => { // Allow polling while disconnected — the agent is still working // server-side and the poller is the only way to see it. if (get(streaming) && get(connectionState) === 'connected') return if (pollingSessionId !== sessionId || get(currentSession) !== sessionId) return const msgs = await fetchMessages(sessionId) if (get(streaming) || pollingSessionId !== sessionId) return // re-check: the fetch itself takes time const incoming = toChatMessages(msgs) if (chatMessagesChanged(get(messages), incoming)) { sessionMessages.set(msgs) messages.set(incoming) } }, 3000) } export function stopPolling() { if (pollTimer) { clearInterval(pollTimer) pollTimer = null } pollingSessionId = null } export function sendMessage(text: string) { error.set(null) streaming.set(true) const userMsg: ChatMessage = { id: mid(), role: 'user', text, tools: [], pendingApprovals: [] } messages.update((ms) => [...ms, userMsg]) const assistantMsg: ChatMessage = { id: mid(), role: 'assistant', text: '', tools: [], pendingApprovals: [] } messages.update((ms) => [...ms, assistantMsg]) const 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 let receivedDone = false 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"). 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, id: ev.data.id, args: ev.data.args } activeTools.set(ev.data.id, tr) messages.update((ms) => { const last = ms[ms.length - 1] if (last && last.role === 'assistant') { ms[ms.length - 1] = { ...last, tools: [...last.tools, tr] } } return [...ms] }) } else if (ev.type === 'tool_result') { const existing = activeTools.get(ev.data.id) if (existing) { const updated: ToolCallResult = { ...existing, type: 'tool_result', result: ev.data.result, error: ev.data.error } activeTools.set(ev.data.id, updated) messages.update((ms) => { const last = ms[ms.length - 1] if (last && last.role === 'assistant') { const tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t)) ms[ms.length - 1] = { ...last, tools, pendingApprovals: extractApprovals(tools) } } return [...ms] }) } } else if (ev.type === 'text_delta') { messages.update((ms) => { const last = ms[ms.length - 1] if (last && last.role === 'assistant') { ms[ms.length - 1] = { ...last, text: last.text + ev.data } } return [...ms] }) } else if (ev.type === 'text') { // Final authoritative content for the turn; replaces accumulated deltas. messages.update((ms) => { const last = ms[ms.length - 1] if (last && last.role === 'assistant') { if (ev.is_thinking) { ms[ms.length - 1] = { ...last, thinking: (last.thinking || '') + ev.data, text: '' } } else { ms[ms.length - 1] = { ...last, text: ev.data } } } return [...ms] }) } else if (ev.type === 'done') { receivedDone = true connectionState.set('connected') messages.update((ms) => { const last = ms[ms.length - 1] if (last && last.role === 'assistant') { ms[ms.length - 1] = { ...last, pendingApprovals: extractApprovals(last.tools) } } return [...ms] }) const sid = ev.data?.session_id ?? ev.session_id // 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. (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) => { // Distinguish user abort from network drop. if (err === 'AbortError' || err.includes('aborted')) { if (get(currentSession) === streamSessionID) streaming.set(false) return } // Network blip / server restart — initiate reconnect. if (get(currentSession) === streamSessionID) { error.set(err) if (!receivedDone && streamSessionID) { handleDisconnect(streamSessionID) } else { streaming.set(false) } } }, () => { // SSE stream completed without error. If we never received 'done', // the connection was severed mid-turn — treat as disconnect. if (!receivedDone && streamSessionID && get(currentSession) === streamSessionID) { handleDisconnect(streamSessionID) } else if (get(currentSession) === streamSessionID) { streaming.set(false) } 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 } } // handleDisconnect is called when the global SSE stream drops mid-turn // without a 'done' event. NOTE: the global single-session chat action path // (sendMessage → this) is not currently wired to any UI — only the // per-session window path (sendSessionMessage/startTask) is live, which has // its own inline equivalent. This is kept safe and turn-free in case the // global path is re-wired: it falls back to polling and surfaces one // connection toast; recovery is driven by the poller + the terminal // task.status subscription (clearTurnState), NEVER by POSTing an empty // message that would spawn a duplicate background turn (F1/F2). function handleDisconnect(sessionId: string) { connectionState.set('disconnected') startPolling(sessionId) addChatError( 'Connection to the agent dropped. The task keeps running — it will catch up here automatically.', 'Dismiss', 'connection' ) } // Manual reconnect for the global view (currently unused — windows use // loadSessionChat via their onReconnect). Re-fetches the transcript and // resets state; does NOT start a new turn. export function reconnect() { const sid = get(currentSession) if (!sid) return streaming.set(false) connectionState.set('connected') chatErrors.update((errs) => errs.filter((e) => e.tag !== 'connection')) loadSessionMessages(sid) } export function newChat() { cancelStream() stopPolling() connectionState.set('connected') currentSession.set(null) messages.set([]) error.set(null) chatErrors.set([]) 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() { 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) { const ok = await apiDeleteSession(sessionId) if (!ok) return if (get(currentSession) === sessionId) { newChat() } loadSessions() } // ─── per-session chat state, for floating task windows ───────────────────── // // Everything above this point is the single "whatever's on screen" view used // by the main Chat page and the chat drawer — one global `currentSession`, // one `messages` array, guarded so a background stream never clobbers the // view. Floating task windows break that assumption: several sessions can be // open and legitimately streaming at once, each wanting its own live // transcript. Rather than retrofit the guard-heavy logic above (streamed // events checking `get(currentSession) === streamSessionID` before applying), // each window gets its own isolated store bundle keyed by session id, so // there's nothing to guard — events for session X always land in X's own // bundle regardless of what else is open or on screen. export interface SessionChatState { messages: Writable streaming: Writable connectionState: Writable<'connected' | 'disconnected' | 'reconnecting'> error: Writable // Set by loadSessionChat when the backend 404s the session outright // (deleted, or an id that was never valid — a stale persisted window, a // bad deep link). Distinct from a merely-empty transcript, which is the // normal state for a session that exists but hasn't sent a message yet. notFound: Writable } const sessionChats = new Map() const sessionPollers = new Map>() // Lazily creates (and memoizes) the store bundle for a session — call this to // get the stores to subscribe to; it does not fetch anything. export function chatFor(sessionId: string): SessionChatState { ensureChatEventSync() // arm the terminal task.status → clearTurnState recovery (P2.2) let c = sessionChats.get(sessionId) if (!c) { c = { messages: writable([]), streaming: writable(false), connectionState: writable('connected'), error: writable(null), notFound: writable(false) } sessionChats.set(sessionId, c) } return c } function startSessionPolling(sessionId: string) { const existing = sessionPollers.get(sessionId) if (existing) clearInterval(existing) const chat = chatFor(sessionId) sessionPollers.set( sessionId, setInterval(async () => { if (get(chat.streaming) && get(chat.connectionState) === 'connected') return const msgs = await fetchMessages(sessionId) if (get(chat.streaming)) return // re-check: the fetch itself takes time const incoming = toChatMessages(msgs) if (chatMessagesChanged(get(chat.messages), incoming)) { chat.messages.set(incoming) } // F3 safety net: if we're recovering from a dropped SSE but the // session's task has already reached a turn-ended status, clear the // stuck disconnected/streaming flags. Catches the edge where the // terminal task.status event fired during the brief disconnect window. if (get(chat.connectionState) !== 'connected') { const s = get(sessions).find((x) => x.id === sessionId) if (s?.status && TURN_ENDED_STATUS.has(s.status)) { clearTurnState(sessionId) } } }, 3000) ) } export function stopSessionPolling(sessionId: string) { const t = sessionPollers.get(sessionId) if (t) { clearInterval(t) sessionPollers.delete(sessionId) } } // Fetches sessionId's current transcript into its own store bundle and // starts polling it for auto-continuation updates — the per-session // equivalent of loadSessionMessages, for a window rather than the main view. export async function loadSessionChat(sessionId: string): Promise { const chat = chatFor(sessionId) // A fresh (re)load is a clean view: not streaming, connected, no stale // error. This also serves the window's manual "Reconnect" button — // re-fetching the transcript and resetting state, never spawning a new // turn (the old reconnect path POSTed an empty message that started a // duplicate background turn; F1/F2 removed that). chat.streaming.set(false) chat.connectionState.set('connected') chat.error.set(null) chatErrors.update((errs) => errs.filter((e) => e.tag !== 'connection')) const msgs = await fetchMessagesOrNotFound(sessionId) if (msgs === null) { chat.notFound.set(true) return // nothing to poll — the session doesn't exist } chat.messages.set(toChatMessages(msgs)) startSessionPolling(sessionId) } // Per-session equivalent of sendMessage — writes into sessionId's own store // bundle unconditionally (no "is this still on screen" guard needed, since // the bundle IS the screen for this session's window) and shares // `activeControllers` with the singleton path above so cancelStream() from // either a window or the main view (if the same session happens to be open // in both) finds the same in-flight call. export function sendSessionMessage(sessionId: string, text: string) { const chat = chatFor(sessionId) chat.error.set(null) chat.streaming.set(true) const userMsg: ChatMessage = { id: mid(), role: 'user', text, tools: [], pendingApprovals: [] } chat.messages.update((ms) => [...ms, userMsg]) const assistantMsg: ChatMessage = { id: mid(), role: 'assistant', text: '', tools: [], pendingApprovals: [] } chat.messages.update((ms) => [...ms, assistantMsg]) const activeTools: Map = new Map() let receivedDone = false const controller = streamChat( text, sessionId, (ev: ChatEvent) => { if (ev.type === 'session') return // sessionId is already known for a window if (ev.type === 'queued') { // This message was queued behind an in-flight turn (plan 2026-08-03 // F2): no assistant stream is attached to this response. Drop the // optimistic empty assistant bubble so the user message is the last // thing on screen — the thread then shows a "Queued" hint while the // session is working, and the poller surfaces the queued turn's // result once it runs server-side. dropOptimisticAssistantBubble(chat.messages) return } if (ev.type === 'tool_use') { const tr: ToolCallResult = { type: 'tool_use', name: ev.data.name, id: ev.data.id, args: ev.data.args } activeTools.set(ev.data.id, tr) chat.messages.update((ms) => { const last = ms[ms.length - 1] if (last && last.role === 'assistant') { ms[ms.length - 1] = { ...last, tools: [...last.tools, tr] } } return [...ms] }) } else if (ev.type === 'tool_result') { const existing = activeTools.get(ev.data.id) if (existing) { const updated: ToolCallResult = { ...existing, type: 'tool_result', result: ev.data.result, error: ev.data.error } activeTools.set(ev.data.id, updated) chat.messages.update((ms) => { const last = ms[ms.length - 1] if (last && last.role === 'assistant') { const tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t)) ms[ms.length - 1] = { ...last, tools, pendingApprovals: extractApprovals(tools) } } return [...ms] }) } } else if (ev.type === 'text_delta') { chat.messages.update((ms) => { const last = ms[ms.length - 1] if (last && last.role === 'assistant') { ms[ms.length - 1] = { ...last, text: last.text + ev.data } } return [...ms] }) } else if (ev.type === 'text') { chat.messages.update((ms) => { const last = ms[ms.length - 1] if (last && last.role === 'assistant') { if (ev.is_thinking) { ms[ms.length - 1] = { ...last, thinking: (last.thinking || '') + ev.data, text: '' } } else { ms[ms.length - 1] = { ...last, text: ev.data } } } return [...ms] }) } else if (ev.type === 'done') { receivedDone = true chat.connectionState.set('connected') chat.messages.update((ms) => { const last = ms[ms.length - 1] if (last && last.role === 'assistant') { ms[ms.length - 1] = { ...last, pendingApprovals: extractApprovals(last.tools) } } return [...ms] }) startSessionPolling(sessionId) } else if (ev.type === 'error') { chat.error.set(humanizeChatError(ev.data)) } }, (err: string) => { if (err === 'AbortError' || err.includes('aborted')) { chat.streaming.set(false) return } // Network drop (no 'done' received): show ONE connection-lost surface // and recover via the poller + terminal task.status event (F2/F3). // Don't also set chat.error — the banner+toast convey it, and a raw // "Failed to fetch" alongside would just be noise. if (!receivedDone) { chat.connectionState.set('disconnected') startSessionPolling(sessionId) addChatError( 'Connection to the agent dropped. The task keeps running — it will catch up here automatically.', 'Dismiss', 'connection' ) } else { // Stream ended cleanly but fetch reported an error tail — surface it. chat.error.set(humanizeChatError(err)) chat.streaming.set(false) } }, () => { chat.streaming.set(false) if (activeControllers.get(sessionId) === controller) activeControllers.delete(sessionId) loadSessions() } ) activeControllers.set(sessionId, controller) } export function cancelSessionStream(sessionId: string) { const controller = activeControllers.get(sessionId) if (!controller) return controller.abort() activeControllers.delete(sessionId) chatFor(sessionId).streaming.set(false) } // ─── new-task launcher (desktop center input / Tasks app) ─────────────────── // // Starting a brand-new task has no session id to hang a window off of until // the stream's own 'session' event assigns one (see the 'session' branch in // sendMessage above) — the desktop launcher needs to open that task's window // the moment an id exists, not before. startTask begins the stream // immediately, buffers any events that arrive before 'session' (defensive: // in practice 'session' always arrives first), then seeds that session's own // chatFor() bundle exactly like sendSessionMessage does and hands the id back // via onSession so the caller can open its window. From that point on the // window behaves exactly like any other task window. export function startTask(text: string, onSession: (sessionId: string) => void): void { const userMsg: ChatMessage = { id: mid(), role: 'user', text, tools: [], pendingApprovals: [] } const assistantMsg: ChatMessage = { id: mid(), role: 'assistant', text: '', tools: [], pendingApprovals: [] } const activeTools: Map = new Map() let receivedDone = false let sessionId: string | null = null let chat: SessionChatState | null = null const buffered: ChatEvent[] = [] function apply(ev: ChatEvent) { const c = chat if (!c || !sessionId) return if (ev.type === 'queued') { // Defensive: a brand-new task won't normally queue (its session has no // in-flight turn), but handle it symmetrically with sendSessionMessage — // drop the optimistic empty assistant bubble. See plan 2026-08-03 F2. dropOptimisticAssistantBubble(c.messages) return } if (ev.type === 'tool_use') { const tr: ToolCallResult = { type: 'tool_use', name: ev.data.name, id: ev.data.id, args: ev.data.args } activeTools.set(ev.data.id, tr) c.messages.update((ms) => { const last = ms[ms.length - 1] if (last && last.role === 'assistant') { ms[ms.length - 1] = { ...last, tools: [...last.tools, tr] } } return [...ms] }) } else if (ev.type === 'tool_result') { const existing = activeTools.get(ev.data.id) if (existing) { const updated: ToolCallResult = { ...existing, type: 'tool_result', result: ev.data.result, error: ev.data.error } activeTools.set(ev.data.id, updated) c.messages.update((ms) => { const last = ms[ms.length - 1] if (last && last.role === 'assistant') { const tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t)) ms[ms.length - 1] = { ...last, tools, pendingApprovals: extractApprovals(tools) } } return [...ms] }) } } else if (ev.type === 'text_delta') { c.messages.update((ms) => { const last = ms[ms.length - 1] if (last && last.role === 'assistant') { ms[ms.length - 1] = { ...last, text: last.text + ev.data } } return [...ms] }) } else if (ev.type === 'text') { c.messages.update((ms) => { const last = ms[ms.length - 1] if (last && last.role === 'assistant') { if (ev.is_thinking) { ms[ms.length - 1] = { ...last, thinking: (last.thinking || '') + ev.data, text: '' } } else { ms[ms.length - 1] = { ...last, text: ev.data } } } return [...ms] }) } else if (ev.type === 'done') { receivedDone = true c.connectionState.set('connected') c.messages.update((ms) => { const last = ms[ms.length - 1] if (last && last.role === 'assistant') { ms[ms.length - 1] = { ...last, pendingApprovals: extractApprovals(last.tools) } } return [...ms] }) startSessionPolling(sessionId) } else if (ev.type === 'error') { c.error.set(humanizeChatError(ev.data)) } } const controller = streamChat( text, null, (ev: ChatEvent) => { if (ev.type === 'session') { sessionId = ev.data activeControllers.set(sessionId, controller) chat = chatFor(sessionId) chat.streaming.set(true) chat.messages.update((ms) => [...ms, userMsg, assistantMsg]) onSession(sessionId) for (const b of buffered.splice(0)) apply(b) return } if (!chat) { buffered.push(ev) return } apply(ev) }, (err: string) => { if (!chat) return // never got a session id — nothing to show the error in if (err === 'AbortError' || err.includes('aborted')) { chat.streaming.set(false) return } if (!receivedDone && sessionId) { chat.connectionState.set('disconnected') startSessionPolling(sessionId) addChatError( 'Connection to the agent dropped. The task keeps running — it will catch up here automatically.', 'Dismiss', 'connection' ) } else { chat.error.set(humanizeChatError(err)) chat.streaming.set(false) } }, () => { if (chat) chat.streaming.set(false) if (sessionId && activeControllers.get(sessionId) === controller) activeControllers.delete(sessionId) loadSessions() } ) }