import { readEntryContent, readHistory, sendMessage, uuidv7, type ContentPart, type HistoryEntry, type Phase } from './api'; import type { Trace } from './burst'; import type { Attached } from './chat/attachments'; import type { Usage } from './context'; import { follow, type Batch } from './stream'; import { formatGap, outcome, type Mood } from './moods'; // One session's transcript as the chat shows it. Entries arrive as history // pages; blocks are what the chat draws. Content a page only referenced is // fetched when it is needed: message text at once, tool output on opening. export type Block = | { kind: 'user'; key: string; text: string; images: Picture[]; from: string | null; gap: string | null; state: 'sending' | 'sent' | 'seen' | 'failed'; error?: string } | { kind: 'assistant'; key: string; text: string; plate: boolean } | { kind: 'thinking'; key: string; text: string; ms: number | null } | { kind: 'tool'; key: string; name: string; entry: HistoryEntry; done: boolean; trace?: Trace; failed: boolean } | { kind: 'note'; key: string; text: string } | { kind: 'compaction'; key: string; text: string } | { kind: 'end'; key: string; turnId: string | null; from: number; mood: Mood; seed: number; elapsedMs: number; tools: number; text: string; changed: { files: number; added: number; removed: number } } | { kind: 'progress'; key: string; name: string; title?: string | null; preview: string }; /** An artifact a cell made or revised: its id, the version, and its title. */ export interface Made { id: string; version: number; title: string; } /** The artifacts a trace says its cell made or revised. */ export function madeIn(trace: Trace | undefined): Made[] { return (trace?.activities ?? []).flatMap((activity) => activity.kind === 'artifact' && activity.artifact ? [{ id: activity.artifact.id, version: activity.artifact.version, title: activity.target }] : [], ); } /** Output the session is writing now, before it commits: one message of thinking or reply. */ interface Live { id: string; kind: 'text' | 'thinking'; text: string; } /** A tool call running now, as its progress reports it. */ interface Progress { id: string; /** The model's id for the call, which its tool_call entry carries too. */ call: string; name: string; title?: string | null; preview: string; } interface Turn { id: string | null; /** The turn's first history position, where a review starts reading. */ from: number; start: number; last: number; tools: number; answered: boolean; files: Set; added: number; removed: number; /** The turn's last reply, which its copy action copies. */ text: string; } /** The pause before your message that earns a "later" note, as in the TUI. */ const AWAY_MS = 30 * 60_000; /** A turn's run of thinking and tool use, drawn as one collapsible step list. */ export type Group = { kind: 'work'; key: string; steps: Block[] } | Block; /** An image in a message: where to load it, and its size when known. */ export interface Picture { src: string; width?: number; height?: number; } interface Pending { id: string; text: string; images: Attached[]; /** The daemon has it, waiting for the loop to take it into the conversation. */ accepted?: boolean; error?: string; } /** The images a user entry carries; the daemon serves each under its reference. */ function picturesOf(entry: HistoryEntry): Picture[] { return entry.content.flatMap((part) => (part.kind === 'image' && part.image.reference ? [{ src: `/api${part.image.reference.url}`, width: part.image.width, height: part.image.height }] : [])); } function textOf(content: ContentPart[]): string { return content.map((part) => (part.kind === 'text' ? part.text : '')).join(''); } /** The value of a JSON field of an entry, if the page carried it inline. */ export function jsonField(entry: HistoryEntry, field: string): unknown { for (const part of entry.content) if (part.kind === 'json' && part.field === field) return part.value; return undefined; } function traceOf(entry: HistoryEntry): Trace | undefined { for (const part of entry.content) if (part.kind === 'trace') return part.trace; return undefined; } /** A python cell that raised, read from its result envelope. */ function failedTool(entry: HistoryEntry): boolean { const result = jsonField(entry, 'result'); if (typeof result !== 'string') return false; try { return JSON.parse(result)?.status === 'error'; } catch { return false; } } function progressOf(progress: any): Progress { return { id: progress?.call_id ?? '', call: progress?.tool_call_id ?? '', name: progress?.name ?? 'tool', title: progress?.title ?? undefined, preview: progress?.preview?.text ?? '' }; } const at = (entry: HistoryEntry) => (entry.created_at ? Date.parse(entry.created_at) : 0); function referenced(entry: HistoryEntry): boolean { return entry.content.some((part) => part.kind === 'reference' && !part.reference.field.startsWith('image-')); } /** What a tool result that just arrived made, reading its trace if the page only referenced it. */ async function madeBy(entry: HistoryEntry): Promise { if (entry.kind !== 'tool_result') return []; const traced = entry.content.some((part) => part.kind === 'reference' && part.reference.field === 'trace') ? await hydrate(entry) : entry; return madeIn(traceOf(traced)); } /** Replaces an entry's references with the content they name. */ export async function hydrate(entry: HistoryEntry): Promise { if (!referenced(entry)) return { ...entry, content_complete: true }; const urls = new Set(entry.content.flatMap((part) => (part.kind === 'reference' ? [part.reference.url] : []))); const loaded = new Map>(); await Promise.all([...urls].map(async (url) => loaded.set(url, await readEntryContent(url)))); const content = entry.content.map((part): ContentPart => { if (part.kind !== 'reference' || part.reference.field.startsWith('image-')) return part; const { url, field } = part.reference; const raw = loaded.get(url)?.[field] ?? ''; if (field === 'text') return { kind: 'text', text: raw }; if (field === 'trace') return { kind: 'trace', trace: JSON.parse(raw) }; return { kind: 'json', field, value: JSON.parse(raw) }; }); return { ...entry, content, content_complete: true }; } export class Transcript { entries = $state.raw([]); pending = $state.raw([]); loading = $state(true); /** Whether the session is busy, so its newest turn is not signed off yet. */ running = $state(false); /** The phase the stream last reported, fresher than the session list's. */ phase = $state(null); streaming = $state(false); live = $state.raw([]); progress = $state.raw([]); #stop: (() => void) | null = null; #settle: ReturnType | undefined; #replacing: 'all' | string[] = []; more = $state(false); error = $state(null); /** * Artifacts made or revised since the chat opened, oldest first, so the * chat can open them as they arrive; history read on opening never adds. */ made = $state.raw([]); /** The newest request's usage, from the stream's snapshot and its usage events; null before the first. */ usage = $state.raw(null); blocks = $derived(this.#blocks()); groups = $derived(group(this.blocks)); /** Your newest message, until a reply follows it, so the chat can say whether the model has it. */ receipt = $derived(this.#receipt()); #expanding = new Set(); #polling = false; constructor(readonly sessionId: string) {} async load() { try { const page = await readHistory(this.sessionId); this.#merge(await this.#hydrateText(page.items)); this.more = page.older !== null; this.error = null; } catch (error) { this.error = String((error as Error).message ?? error); } finally { this.loading = false; } } async loadOlder() { const first = this.entries[0]; if (!first || !this.more) return; const page = await readHistory(this.sessionId, { before: first.position }); const known = new Set(this.entries.map((entry) => entry.id)); this.#merge(await this.#hydrateText(page.items.filter((entry) => !known.has(entry.id)))); this.more = page.older !== null; } /** * Adds the entries the chat does not have yet, in history order, and * returns them. Reads overlap (a poll racing the first load, or a load * after a reconnect), so an entry may arrive twice; a duplicate key would * stop the whole chat from updating. */ #merge(arrived: HistoryEntry[]): HistoryEntry[] { const known = new Set(this.entries.map((entry) => entry.id)); const added = arrived.filter((entry) => !known.has(entry.id)); if (added.length) this.entries = [...this.entries, ...added].sort((a, b) => a.position - b.position); return added; } /** Reads everything after the newest entry. One poll runs at a time. */ async poll() { if (this.#polling) return; this.#polling = true; // A read racing the first load may return history from before the chat // opened; only what follows that load counts as arriving. const opened = !this.loading; try { for (;;) { const last = this.entries.at(-1); const page = await readHistory(this.sessionId, last ? { after: last.position } : {}); const known = new Set(this.entries.map((entry) => entry.id)); const fresh = page.items.filter((entry) => !known.has(entry.id)); if (fresh.length) { const hydrated = await this.#hydrateText(fresh); const made = opened ? (await Promise.all(hydrated.map(madeBy))).flat() : []; const arrived = this.#merge(hydrated); if (made.length) this.made = [...this.made, ...made]; const answered = new Set(arrived.map((entry) => entry.input_id)); this.pending = this.pending.filter((send) => !answered.has(send.id)); if (arrived.some((entry) => ['thinking', 'assistant', 'tool_call'].includes(entry.kind))) this.#clearReplaced(); } if (!page.newer || !fresh.length) return; } } finally { this.#polling = false; } } /** Follows the session's stream: live output as it is written, and history as it commits. */ attach() { this.#stop ??= follow(this.sessionId, (batch) => this.#apply(batch), (connected) => (this.streaming = connected)); } detach() { this.#stop?.(); this.#stop = null; clearTimeout(this.#settle); } #apply(batch: Batch) { for (const event of batch.events) { const data = event.data ?? {}; switch (event.type) { case 'reset': { const snapshot = batch.snapshot ?? {}; this.phase = snapshot.status?.phase ?? null; if (snapshot.usage) this.usage = snapshot.usage.observed_at ? snapshot.usage : null; this.live = (snapshot.active_output ?? []).flatMap((output: any) => output.kind === 'text' || output.kind === 'thinking' ? [{ id: output.message_id, kind: output.kind, text: output.text ?? '…' }] : [], ); this.progress = (snapshot.current_progress ?? []).map(progressOf); this.#commit(null); break; } case 'status': this.phase = data.phase ?? null; if (this.phase === 'idle') this.#commit(null); break; case 'usage': this.usage = data; break; case 'text': case 'thinking': { const at = this.live.findIndex((item) => item.id === data.message_id); if (at < 0) this.live = [...this.live, { id: data.message_id, kind: event.type, text: data.text }]; else this.live = this.live.map((item, index) => (index === at ? { ...item, text: item.text + data.text } : item)); break; } case 'tool_progress': { const next = progressOf(data.progress); this.progress = [...this.progress.filter((item) => item.id !== next.id), next]; break; } case 'tool': this.progress = this.progress.filter((item) => item.id !== data.progress_call_id); this.#commit(null); break; case 'committed': this.#commit(data.replaces_all_live ? 'all' : (data.replaces_live_ids ?? [])); break; case 'message': case 'note': this.#commit(null); break; } } } /** * Reads what committed, then drops the live output it replaces, so a reply * never blinks out between its last delta and its history entry. */ #commit(replaces: 'all' | string[] | null) { // Events arrive in bursts (a commit, then its tool, then a status), so // one read serves them all; what each burst replaces adds up rather than // the last event's list winning. if (replaces === 'all') this.#replacing = 'all'; else if (replaces && this.#replacing !== 'all') this.#replacing = [...this.#replacing, ...replaces]; clearTimeout(this.#settle); this.#settle = setTimeout(async () => { await this.poll().catch(() => {}); this.#clearReplaced(); if (this.phase === 'idle') [this.live, this.progress] = [[], []]; }, 60); } #clearReplaced() { const replacing = this.#replacing; this.#replacing = []; if (replacing === 'all') this.live = []; else if (replacing.length) this.live = this.live.filter((item) => !replacing.includes(item.id)); } async send(text: string, images: Attached[] = []) { const send = { id: uuidv7(), text, images }; this.pending = [...this.pending, send]; await this.#deliver(send); } async retry(id: string) { const send = this.pending.find((candidate) => candidate.id === id); if (!send) return; this.pending = this.pending.map((candidate) => (candidate.id === id ? { ...send, error: undefined } : candidate)); await this.#deliver(send); } discard(id: string) { this.pending = this.pending.filter((send) => send.id !== id); } #receipt(): string | null { for (const block of this.blocks.toReversed()) { if (block.kind === 'assistant') return null; if (block.kind === 'user' && !block.from) return block.key; } return null; } /** Where a turn starts among the loaded entries, for a review of it. */ turnStart(turnId: string): number | null { return this.entries.find((entry) => entry.turn_id === turnId)?.position ?? null; } /** Puts tool entries' full arguments and output in place of their references. */ async expand(...entries: HistoryEntry[]) { const waiting = entries.filter((entry) => !entry.content_complete && !this.#expanding.has(entry.id) && referenced(entry)); if (!waiting.length) return; for (const entry of waiting) this.#expanding.add(entry.id); try { const full = new Map((await Promise.all(waiting.map(hydrate))).map((entry) => [entry.id, entry])); this.entries = this.entries.map((candidate) => full.get(candidate.id) ?? candidate); } finally { for (const entry of waiting) this.#expanding.delete(entry.id); } } async #deliver(send: Pending) { try { await sendMessage(this.sessionId, send.id, send.text, send.images.map(({ mime_type, data }) => ({ mime_type, data }))); this.pending = this.pending.map((candidate) => (candidate.id === send.id ? { ...candidate, accepted: true } : candidate)); } catch (error) { const message = String((error as Error).message ?? error); this.pending = this.pending.map((candidate) => (candidate.id === send.id ? { ...candidate, error: message } : candidate)); } } // Message text is what the chat is for; fetch it up front. Tool content waits. #hydrateText(entries: HistoryEntry[]) { return Promise.all(entries.map((entry) => (['user', 'assistant', 'thinking', 'note', 'compaction'].includes(entry.kind) ? hydrate(entry) : entry))); } #blocks(): Block[] { const results = new Set(this.entries.flatMap((entry) => (entry.kind === 'tool_result' && entry.tool ? [entry.tool.tool_call_id] : []))); const blocks: Block[] = []; let turn = null as Turn | null; let previous = 0; let speaker = ''; // A turn signs off with a face once the next one starts or the session rests. const signOff = () => { if (!turn || (!turn.answered && !turn.tools)) return; const elapsedMs = Math.max(0, turn.last - turn.start); const changed = { files: turn.files.size, added: turn.added, removed: turn.removed }; blocks.push({ kind: 'end', key: `end:${turn.id}`, turnId: turn.id, from: turn.from, mood: outcome(false, false, elapsedMs), seed: turn.last, elapsedMs, tools: turn.tools, text: turn.text, changed }); }; for (const entry of this.entries) { const text = textOf(entry.content); if (!turn || entry.turn_id !== turn.id) { signOff(); turn = { id: entry.turn_id, from: entry.position, start: at(entry), last: at(entry), tools: 0, answered: false, files: new Set(), added: 0, removed: 0, text: '' }; } turn.last = Math.max(turn.last, at(entry)); switch (entry.kind) { case 'user': { const gap = previous && at(entry) - previous >= AWAY_MS ? formatGap(at(entry) - previous) : null; const from = entry.mail?.sender_label?.toLowerCase() ?? (entry.turn_type && !['user', 'chat'].includes(entry.turn_type) ? entry.turn_type : null); blocks.push({ kind: 'user', key: entry.id, text, images: picturesOf(entry), from, gap, state: 'seen' }); speaker = 'you'; break; } case 'assistant': if (!text.trim()) break; blocks.push({ kind: 'assistant', key: entry.id, text, plate: speaker !== 'albedo' }); speaker = 'albedo'; turn.answered = true; turn.text = text; break; case 'thinking': if (text.trim()) blocks.push({ kind: 'thinking', key: entry.id, text, ms: entry.thinking_duration_ms ?? null }); break; case 'tool_call': // A call shows until its result replaces it. if (entry.tool && !results.has(entry.tool.tool_call_id)) blocks.push({ kind: 'tool', key: entry.id, name: entry.tool.name, entry, done: false, failed: false }); break; case 'tool_result': if (!entry.tool) break; turn.tools++; for (const change of traceOf(entry)?.changes ?? []) { turn.files.add(change.path); if (change.kind === 'diff') { turn.added += change.added; turn.removed += change.removed; } } blocks.push({ kind: 'tool', key: entry.id, name: entry.tool.name, entry, done: true, trace: traceOf(entry), failed: failedTool(entry) }); break; case 'note': case 'image_fit': if (text.trim()) blocks.push({ kind: 'note', key: entry.id, text }); break; case 'compaction': blocks.push({ kind: 'compaction', key: entry.id, text }); break; } if (at(entry)) previous = at(entry); } if (!this.running && !this.pending.length) signOff(); for (const item of this.live) { if (!item.text.trim()) continue; if (item.kind === 'thinking') blocks.push({ kind: 'thinking', key: `live:${item.id}`, text: item.text, ms: null }); else blocks.push({ kind: 'assistant', key: `live:${item.id}`, text: item.text, plate: speaker !== 'albedo' }); } // A call whose entry has committed already shows as that entry. const called = new Set(this.entries.flatMap((entry) => (entry.kind === 'tool_call' && entry.tool ? [entry.tool.tool_call_id] : []))); for (const item of this.progress) { if (!called.has(item.call)) blocks.push({ kind: 'progress', key: `progress:${item.id}`, name: item.name, title: item.title, preview: item.preview }); } for (const send of this.pending) { const images = send.images.map((image) => ({ src: image.preview })); blocks.push({ kind: 'user', key: `pending:${send.id}`, text: send.text, images, from: null, gap: null, state: send.error ? 'failed' : send.accepted ? 'sent' : 'sending', error: send.error }); } return blocks; } } function group(blocks: Block[]): Group[] { const groups: Group[] = []; for (const block of blocks) { const last = groups.at(-1); if (block.kind === 'thinking' || block.kind === 'tool' || block.kind === 'progress') { if (last?.kind === 'work') last.steps.push(block); else groups.push({ kind: 'work', key: last ? `work:${last.key}` : 'work:root', steps: [block] }); } else groups.push(block); } return groups; }