import { useSyncExternalStore } from "react"; import { get } from "./api"; import type { QueueItem, User, McpServerStatus, Message, Part, PermissionRequest, QuestionRequest, ServerEvent, Session, } from "./types"; /** The assistant message currently being streamed. */ export interface Draft { id: string; parts: Part[]; } export interface SessionError { id: number; message: string; } export interface State { me: User | null; /** Newer release (admins). */ update: { version: string; url: string; notes: string } | null; connected: boolean; sessions: Session[]; /** Includes subagent sessions loaded on demand. */ sessionById: Record; messages: Record; /** Sessions the current user may only read (shared with them). */ readOnly: Record; drafts: Record; busy: Record; progress: Record; permissions: PermissionRequest[]; questions: QuestionRequest[]; errors: Record; mcp: Record; /** The model request currently running per session (activity line). */ steps: Record; /** Messages waiting for a busy session. */ queue: Record; /** Why a session waits: "model:" or "subagent:". */ waiting: Record; } let state: State = { me: null, update: null, connected: false, sessions: [], sessionById: {}, messages: {}, readOnly: {}, drafts: {}, busy: {}, progress: {}, permissions: [], questions: [], errors: {}, mcp: {}, queue: {}, steps: {}, waiting: {}, }; const listeners = new Set<() => void>(); export function setState(fn: (s: State) => Partial) { state = { ...state, ...fn(state) }; listeners.forEach((l) => l()); } export function getState() { return state; } export function useStore(selector: (s: State) => T): T { return useSyncExternalStore( (l) => { listeners.add(l); return () => listeners.delete(l); }, () => selector(state), ); } function upsertSession(s: State, session: Session): Partial { const sessionById = { ...s.sessionById, [session.id]: session }; if (session.parent_id) return { sessionById }; const others = s.sessions.filter((x) => x.id !== session.id); const sessions = [session, ...others].sort((a, b) => b.updated_at - a.updated_at); return { sessions, sessionById }; } let errorSeq = 0; function draftFor(s: State, sid: string, mid: string): Draft { const d = s.drafts[sid]; return d && d.id === mid ? { ...d, parts: [...d.parts] } : { id: mid, parts: [] }; } export function applyEvent(ev: ServerEvent) { switch (ev.type) { case "session_updated": setState((s) => upsertSession(s, ev.session)); break; case "session_deleted": setState((s) => { const { [ev.session_id]: _, ...rest } = s.sessionById; return { sessions: s.sessions.filter((x) => x.id !== ev.session_id), sessionById: rest }; }); break; case "session_status": setState((s) => ({ busy: { ...s.busy, [ev.session_id]: ev.busy }, drafts: ev.busy ? s.drafts : { ...s.drafts, [ev.session_id]: undefined }, steps: ev.busy ? s.steps : { ...s.steps, [ev.session_id]: undefined }, })); break; case "message_added": setState((s) => { const list = s.messages[ev.session_id]; const drafts = s.drafts[ev.session_id]?.id === ev.message.id ? { ...s.drafts, [ev.session_id]: undefined } : s.drafts; if (!list) return { drafts }; if (list.some((m) => m.id === ev.message.id)) return { drafts }; return { messages: { ...s.messages, [ev.session_id]: [...list, ev.message] }, drafts }; }); break; case "text_delta": case "reasoning_delta": setState((s) => { const d = draftFor(s, ev.session_id, ev.message_id); const kind = ev.type === "text_delta" ? "text" : "reasoning"; const last = d.parts[d.parts.length - 1]; if (last && last.type === kind) { d.parts[d.parts.length - 1] = { ...last, text: last.text + ev.text } as Part; } else { d.parts.push({ type: kind, text: ev.text } as Part); } return { drafts: { ...s.drafts, [ev.session_id]: d } }; }); break; case "tool_started": setState((s) => { const d = draftFor(s, ev.session_id, ev.message_id); d.parts.push({ type: "tool_call", id: ev.call_id, name: ev.name, input: ev.input }); return { drafts: { ...s.drafts, [ev.session_id]: d } }; }); break; case "tool_progress": setState((s) => ({ progress: { ...s.progress, [ev.call_id]: ev.output } })); break; case "permission_request": setState((s) => ({ permissions: [...s.permissions.filter((p) => p.id !== ev.id), ev] })); break; case "permission_resolved": setState((s) => ({ permissions: s.permissions.filter((p) => p.id !== ev.id) })); break; case "question_request": setState((s) => ({ questions: [...s.questions.filter((q) => q.id !== ev.id), ev] })); notify(ev.questions[0]?.question ?? ""); break; case "question_resolved": setState((s) => ({ questions: s.questions.filter((q) => q.id !== ev.id) })); break; case "error": setState((s) => ({ errors: { ...s.errors, [ev.session_id]: [...(s.errors[ev.session_id] ?? []), { id: ++errorSeq, message: ev.message }].slice(-5), }, })); break; case "step_started": setState((s) => ({ steps: { ...s.steps, [ev.session_id]: { messageId: ev.message_id, startedAt: ev.started_at, promptTokens: ev.prompt_tokens, estimated: ev.estimated } }, })); break; case "queue_updated": setState((s) => ({ queue: { ...s.queue, [ev.session_id]: ev.items } })); break; case "waiting": setState((s) => ({ waiting: { ...s.waiting, [ev.session_id]: ev.reason ?? undefined } })); break; case "mcp_status": setState(() => ({ mcp: ev.servers })); break; case "resync": void resync(); break; } } /** Notify when the app is in the background and an agent needs input. */ function notify(body: string) { if (document.visibilityState === "visible" || !("Notification" in window)) return; // With Web Push on this device the server notifies (avoid duplicates). if (localStorage.getItem("codecraft.push") === "1") return; if (Notification.permission === "granted") { new Notification("CodeCraft", { body, icon: "/icon-192.png" }); } } export function dismissError(sessionId: string, id: number) { setState((s) => ({ errors: { ...s.errors, [sessionId]: (s.errors[sessionId] ?? []).filter((e) => e.id !== id) } })); } export async function loadSessions() { const list = await get("/sessions"); setState((s) => ({ sessions: list, sessionById: { ...s.sessionById, ...Object.fromEntries(list.map((x) => [x.id, x])) }, busy: { ...s.busy, ...Object.fromEntries(list.map((x) => [x.id, !!x.busy])) }, })); } export async function loadSession(id: string) { const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any }>(`/sessions/${id}`); setState((s) => ({ ...upsertSession(s, data.session), sessionById: { ...s.sessionById, [id]: data.session }, messages: { ...s.messages, [id]: data.messages }, busy: { ...s.busy, [id]: data.busy }, readOnly: { ...s.readOnly, [id]: !data.can_write }, queue: { ...s.queue, [id]: data.queue ?? [] }, steps: { ...s.steps, [id]: data.step ? { messageId: data.step.message_id, startedAt: data.step.started_at, promptTokens: data.step.prompt_tokens, estimated: data.step.estimated } : s.steps[id], }, })); } /** After reconnects: reload everything that could have changed. */ export async function resync() { const isAdmin = getState().me?.role === "admin"; const [pending, mcp] = await Promise.all([get("/pending"), isAdmin ? get("/mcp") : Promise.resolve({})]); setState(() => ({ permissions: pending.filter((e) => e.type === "permission_request"), questions: pending.filter((e) => e.type === "question_request"), mcp, })); await loadSessions(); const loaded = Object.keys(getState().messages); await Promise.all(loaded.map((id) => loadSession(id).catch(() => undefined))); }