import { SvelteMap } from 'svelte/reactivity'; import { ApiError, configureSession, getSession, listSessions, type PreferencePatch, type Session } from './api'; import type { HistoryEntry, Usage } from './context'; import { followSessions, type StreamEvent } from './stream'; import { notifications } from './notifications.svelte'; export type Link = 'connecting' | 'online' | 'offline' | 'unauthorized'; // While the list's stream is up, it carries every change and the list is // read again only when it says to (and once a minute, in case). Without it // the list is polled. const POLL_MS = { streaming: 60_000, polling: 4000 }; // One shared root-list follow stays live for background notifications. // Chat streams and regular list polling still pause while hidden. class Sessions { list = $state.raw([]); link = $state('connecting'); loaded = $state(false); streaming = $state(false); /** Why the last pin or archive was refused, until the next one. */ problem = $state(''); // Agents a window shows: they are not in the list, which holds only the // sessions a person started, so a window of one keeps its record here. agents = $state.raw(new Map()); /** The newest usage and history entries each open chat has read, shared with undocked model chips. */ readings = new SvelteMap(); byId = $derived(new Map([...this.agents, ...this.list.map((session): [string, Session] => [session.id, session])])); recordReading(sessionId: string, usage: Usage | null, entries: HistoryEntry[]) { const prev = this.readings.get(sessionId); if (prev && prev.usage === usage && prev.entries === entries) return; this.readings.set(sessionId, { usage, entries }); } clearReading(sessionId: string) { this.readings.delete(sessionId); } #timer: ReturnType | undefined; #inflight: AbortController | undefined; #stopStream: (() => void) | null = null; #soon: ReturnType | undefined; #savedAt = new Map(); start() { document.addEventListener('visibilitychange', () => (document.hidden ? this.#pause() : this.#resume())); notifications.start(); this.#resume(); } async refresh() { clearTimeout(this.#timer); this.#inflight?.abort(); const controller = (this.#inflight = new AbortController()); try { const next = await listSessions(controller.signal); const previous = this.byId; for (const session of next) notifications.changed(previous.get(session.id), session); this.list = next; notifications.prune(next); this.link = 'online'; this.loaded = true; } catch (error) { if (controller.signal.aborted) return; this.link = error instanceof ApiError && error.status === 401 ? 'unauthorized' : 'offline'; } if (!document.hidden || !this.streaming) { const delay = document.hidden ? 30_000 : this.streaming ? POLL_MS.streaming : POLL_MS.polling; this.#timer = setTimeout(() => this.refresh(), delay); } } #resume() { this.#stopStream ??= followSessions( (events) => this.#apply(events), (connected) => { this.streaming = connected; if (!connected) this.#later(0); }, true, ); this.refresh(); } #pause() { clearTimeout(this.#timer); clearTimeout(this.#soon); this.#inflight?.abort(); if (!this.streaming) this.#later(30_000); } /** Keeps an agent's record for the window about to show it. */ adopt(session: Session) { if (!this.list.some((listed) => listed.id === session.id)) this.agents = new Map(this.agents).set(session.id, session); } /** Brings the kept agents up to date from a read of their family. */ refreshAgents(family: Session[]) { const changed = family.filter((session) => this.agents.has(session.id)); if (changed.length) this.agents = new Map([...this.agents, ...changed.map((session): [string, Session] => [session.id, session])]); } /** Whether the session is known, reading an agent's record from the daemon when it is not in the list. */ async ensure(id: string): Promise { if (this.byId.has(id)) return true; try { this.adopt(await getSession(id)); return true; } catch (error) { // Only the daemon saying it has no such session makes it gone; a read that failed is no answer. return !(error instanceof ApiError && error.status === 404); } } /** The pinned sessions, first to last. */ pins = $derived( this.list.filter((session) => session.preferences.pinned).sort((a, b) => (a.preferences.pin_order ?? Infinity) - (b.preferences.pin_order ?? Infinity)), ); /** Pins or archives a session, shown at once and put back if the daemon refuses. */ mark(id: string, change: { pinned?: boolean; archived?: boolean }) { const nextOrder = change.pinned ? Math.max(-1, ...this.pins.map((pin) => pin.preferences.pin_order ?? -1)) + 1 : null; return this.#save([id], id, change, (session) => session.id !== id ? session : { ...session, preferences: { ...session.preferences, ...change, ...(change.pinned === false ? { pin_order: null } : change.pinned === true && session.preferences.pin_order === null ? { pin_order: nextOrder } : {}), }, }, ); } /** Moves a pinned session before another pinned one, or last for null. */ movePin(id: string, before: string | null) { const order = this.pins.map((session) => session.id).filter((pin) => pin !== id); order.splice(before === null ? order.length : order.indexOf(before), 0, id); return this.#save(order, id, { pin_before: before }, (session) => { const place = order.indexOf(session.id); return place < 0 ? session : { ...session, preferences: { ...session.preferences, pin_order: place } }; }); } async #save(touched: string[], id: string, preferences: PreferencePatch, show: (session: Session) => Session) { const previous = new Map( this.list.filter((session) => touched.includes(session.id)).map((session) => [session.id, session.preferences]), ); const now = Date.now(); for (const sid of touched) this.#savedAt.set(sid, now); this.problem = ''; this.list = this.list.map(show); try { const saved = await configureSession(id, { preferences }); const settled = Date.now(); for (const sid of touched) this.#savedAt.set(sid, settled); if (saved?.preferences) { this.list = this.list.map((session) => session.id === id ? { ...session, preferences: { ...session.preferences, ...saved.preferences } } : session, ); } } catch (error) { this.list = this.list.map((session) => { const old = previous.get(session.id); return old ? { ...session, preferences: old } : session; }); this.problem = error instanceof Error ? error.message : 'could not save'; } } /** Reads the list again shortly, once for a burst of changes. */ #later(ms = 150) { clearTimeout(this.#soon); this.#soon = setTimeout(() => this.refresh(), ms); } #apply(events: StreamEvent[]) { let patched = this.list; for (const event of events) { if (event.type === 'activity') { const { session_id: id, status, activity } = event.data ?? {}; const at = patched.findIndex((session) => session.id === id); if (at < 0) { this.#later(); continue; } const current = patched[at]; const next: Session = { ...current, status: status ?? current.status, activity: activity ?? current.activity, activity_at: activity?.observed_at ?? current.activity_at }; notifications.changed(current, next); patched = patched.map((session, index) => (index === at ? next : session)); } else if (event.type === 'mail') notifications.mail(event.data, patched); else if (event.type === 'invalidate') { const ids: string[] = Array.isArray(event.data?.session_ids) ? event.data.session_ids : []; const now = Date.now(); if (!event.data?.scope_dirty && ids.length > 0 && ids.every((sid) => now - (this.#savedAt.get(sid) ?? 0) < 2000)) { continue; } this.#later(); } else if (event.type === 'reset' || event.type === 'overflow') this.#later(); } if (patched !== this.list) this.list = patched; } } export const sessions = new Sessions();