Backend: - Add isThinking flag to agentEvent for text before tool calls - Separate thinking from response text in runChatTurn and continue.go - Persist thinking in a dedicated field in message content Frontend: - Add thinking field to MessageContent, ChatMessage, ChatTextEvent types - Create ThinkingBlock.svelte — collapsible block with brain icon - SSE handler moves text_delta content to thinking on isThinking flag - Render thinking block between tools and response in ChatThread - Fix chat window scroll reset on focus change (stable windowKeys order) - Remove redundant #key id wrapper in WindowLayer - Enlarge sidebar rail (24→32 default, 40→60 max) - Remove glyph from sidebar, square graph at top - Replace AgentTrace/ToolCallCard/UnifiedTimeline with TurnTrace/ToolLine
974 lines
38 KiB
TypeScript
974 lines
38 KiB
TypeScript
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<ChatMessage[]>): 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<ChatMessage[]>([])
|
|
export const streaming = writable(false)
|
|
export const connectionState = writable<'connected' | 'disconnected' | 'reconnecting'>('connected')
|
|
export const currentSession = writable<string | null>(null)
|
|
export const sessions = writable<Session[]>([])
|
|
export const sessionMessages = writable<Message[]>([])
|
|
export const error = writable<string | null>(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<string, AbortController>()
|
|
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<string, ToolCallResult>()
|
|
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<typeof setInterval> | 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<string, ToolCallResult> = 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<ChatMessage[]>
|
|
streaming: Writable<boolean>
|
|
connectionState: Writable<'connected' | 'disconnected' | 'reconnecting'>
|
|
error: Writable<string | null>
|
|
// 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<boolean>
|
|
}
|
|
|
|
const sessionChats = new Map<string, SessionChatState>()
|
|
const sessionPollers = new Map<string, ReturnType<typeof setInterval>>()
|
|
|
|
// 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<void> {
|
|
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<string, ToolCallResult> = 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<string, ToolCallResult> = 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()
|
|
}
|
|
)
|
|
}
|