import { SvelteMap } from 'svelte/reactivity'; import { ApiError, getArtifact, sendMessage, uuidv7, type ArtifactDetail } from './api'; import type { Target } from './board.svelte'; import { selection } from './tie.svelte'; // Artifacts on the client: the details every window and chat row shares, the // colours a page is given, and the window's side of the bridge a page talks // to the chat through (robot-docs/artifacts.md). /** What the client knows of each artifact, read once and again when a newer version is heard of. */ class Artifacts { #details = new SvelteMap(); #problems = new SvelteMap(); #reading = new Map>(); get(id: string): ArtifactDetail | undefined { return this.#details.get(id); } /** Why an artifact cannot be shown, or undefined. */ problem(id: string): string | undefined { return this.#problems.get(id); } /** Reads an artifact unless what is known already has `version`. */ want(id: string, version = 0): Promise { const known = this.#details.get(id); if (known && known.version >= version) return Promise.resolve(); return this.read(id); } /** Reads an artifact again; one read at a time each. */ read(id: string): Promise { const reading = this.#reading.get(id); if (reading) return reading; const next = getArtifact(id) .then((detail) => { this.#details.set(id, detail); this.#problems.delete(id); }) .catch((error) => { this.#problems.set(id, error instanceof ApiError && error.status === 404 ? 'this artifact was deleted' : String((error as Error).message ?? error)); }) .finally(() => this.#reading.delete(id)); this.#reading.set(id, next); return next; } forget(id: string) { this.#details.delete(id); this.#problems.set(id, 'this artifact was deleted'); } } export const artifacts = new Artifacts(); // What a page says of itself is checked before it is drawn or quoted. function rect(value: any): PageRect | null { const fields = [value?.x, value?.y, value?.width, value?.height]; if (!fields.every((field) => typeof field === 'number' && Number.isFinite(field))) return null; return { x: value.x, y: value.y, width: Math.max(0, value.width), height: Math.max(0, value.height) }; } function selector(value: any): value is string { return typeof value === 'string' && !!value && value.length <= 1000; } function passage(value: any): PagePassage | undefined { const [quote, prefix, suffix] = [value?.exact, value?.prefix, value?.suffix]; if (typeof quote !== 'string' || !quote.trim() || typeof prefix !== 'string' || typeof suffix !== 'string') return undefined; return { quote: quote.slice(0, 2000), prefix: prefix.slice(-32), suffix: suffix.slice(0, 32) }; } function lines(value: any): PageRect[] | null { return Array.isArray(value) ? value.slice(0, 64).flatMap((line) => rect(line) ?? []) : null; } function pointed(value: any): Pointed | null { if (typeof value?.x !== 'number' || typeof value?.y !== 'number') return null; const part = rect(value.rect); if (!selector(value.selector) || typeof value.quote !== 'string' || !part) return { x: value.x, y: value.y, selector: null }; return { x: value.x, y: value.y, selector: value.selector, quote: value.quote.slice(0, 601), rect: part, passage: passage(value.passage) }; } // The client's palette, as the page's --albedo-* variables. The client's own // values are expressions over its seed colours (color-mix, var()), which mean // nothing inside the page, so each is resolved to a colour here first. const COLOURS: Record = { bg: 'var(--bg)', surface: 'var(--surface)', text: 'var(--text)', muted: 'var(--muted)', faint: 'var(--faint)', accent: 'var(--accent)', border: 'color-mix(in oklab, var(--text) 14%, var(--surface))', added: 'var(--added)', danger: 'var(--danger)', }; export function themeVariables(): Record { const probe = document.createElement('span'); probe.style.display = 'none'; document.body.append(probe); const variables: Record = {}; for (const [name, value] of Object.entries(COLOURS)) { probe.style.color = value; variables[`--albedo-${name}`] = getComputedStyle(probe).color; } probe.remove(); const root = getComputedStyle(document.documentElement); variables['--albedo-sans'] = root.getPropertyValue('--sans').trim(); variables['--albedo-mono'] = root.getPropertyValue('--mono').trim(); return variables; } /** How many messages a page may send a minute without a click or key press behind each. */ export const UNPROMPTED_PER_MINUTE = 5; type Request = { jsonrpc: '2.0'; id?: string | number; method?: string; params?: any }; /** A part of a page, in the page's own pixels. */ export type PageRect = { x: number; y: number; width: number; height: number }; /** A passage of a page's text: the exact words, and a little of what is either side of them. */ export type PagePassage = { quote: string; prefix: string; suffix: string }; /** * What the page said is under a point of it: the part a note would be tied * to, and when that is only a passage of a larger text, the passage. */ export type Pointed = { x: number; y: number; selector: string; quote: string; rect: PageRect; passage?: PagePassage } | { x: number; y: number; selector: null }; /** How a page knows a passage it reports on. */ export function passageKey(target: { selector: string; quote: string; prefix: string; suffix: string }): string { return JSON.stringify([target.selector, target.prefix, target.quote, target.suffix]); } /** The bridge of each artifact window on the board, by window id, for notes tied into its page. */ export const pageHosts = new Map(); /** * The window's side of one page's bridge: JSON-RPC over postMessage, the * subset of MCP Apps a page here may use. A message sent right after the * person clicked or typed in the page goes to the chat at once; ones the page * sends by itself are held past a few a minute (the loop guard), until the * person sends or discards them. */ export class PageHost { /** Messages the loop guard is holding. */ held = $state([]); /** Failures the page reported since it loaded. */ failures = $state([]); /** The page's own height, as it last said. */ height = $state(null); /** What the page last said is under the pointer, while a note's tie is drawn over it. */ pointed: Pointed | null = null; /** Where each part notes are tied to is now, as the page last said; null when it is gone. */ rects = new Map(); /** The lines of each passage notes are tied to, by `passageKey`, as the page last said; null when it is gone. */ passages = new Map(); #watched: string[] = []; #passages: (PagePassage & { key: string; selector: string })[] = []; #unprompted: number[] = []; constructor( private readonly frame: () => HTMLIFrameElement | undefined, private readonly page: () => { title: string; version: number; session: string | null; theme: 'light' | 'dark' }, ) {} /** Starts over for a page that loaded again. */ reset() { this.failures = []; this.height = null; this.pointed = null; this.rects.clear(); this.passages.clear(); if (selection.current?.page === this) selection.current = null; } /** The version of the page shown. */ get version(): number { return this.page().version; } /** The page itself, on screen, and how much the canvas scales it. */ frameBox(): { rect: DOMRect; scale: number } | null { const frame = this.frame(); if (!frame?.isConnected) return null; const rect = frame.getBoundingClientRect(); return { rect, scale: frame.clientWidth ? rect.width / frame.clientWidth : 1 }; } /** Asks the page what is under a point of the screen; the answer lands in `pointed`. */ point(x: number, y: number) { const box = this.frameBox(); if (!box) return; this.notify('albedo/point', { x: (x - box.rect.left) / box.scale, y: (y - box.rect.top) / box.scale }); } /** Has the page report where the part a selector names is, now and as it moves. */ watch(selector: string) { if (this.#watched.includes(selector)) return; this.#watched = [...this.#watched, selector].slice(-64); this.#follow(); } /** Has the page report the lines of a passage, found by its words inside the part `selector` names. */ watchPassage(target: Extract): string { const key = passageKey(target); if (!this.#passages.some((watched) => watched.key === key)) { const { selector, quote, prefix, suffix } = target; this.#passages = [...this.#passages, { key, selector, quote, prefix, suffix }].slice(-64); this.#follow(); } return key; } #follow() { const passages = this.#passages.map(({ key, selector, quote, prefix, suffix }) => ({ key, selector, exact: quote, prefix, suffix })); this.notify('albedo/watch', { selectors: this.#watched, passages }); } /** Where a page rect is on screen. */ onScreen(part: PageRect): DOMRect | null { const box = this.frameBox(); if (!box) return null; const { rect, scale } = box; return new DOMRect(rect.left + part.x * scale, rect.top + part.y * scale, part.width * scale, part.height * scale); } receive(event: MessageEvent) { const frame = this.frame(); if (!frame || event.source !== frame.contentWindow) return; const message = event.data as Request; if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') return; const { id, method, params } = message; switch (method) { case 'ui/initialize': return this.#answer(id, { protocolVersion: '2026-01-26', hostInfo: { name: 'albedo' }, hostCapabilities: { message: {} }, hostContext: { ...this.context(), displayMode: 'inline' }, }); case 'ui/notifications/initialized': // A page loaded again starts with nothing to watch. if (this.#watched.length || this.#passages.length) this.#follow(); return; case 'ui/message': return void this.#message(id, params); case 'ui/notifications/size-changed': if (typeof params?.height === 'number') this.height = params.height; return; case 'albedo/error': this.failures = [...this.failures.slice(-19), String(params?.message ?? 'something failed')]; return; case 'albedo/wheel': return this.#wheel(params); case 'albedo/pointed': this.pointed = pointed(params); return; case 'albedo/rects': for (const [selector, part] of Object.entries(params?.rects ?? {})) { if (this.#watched.includes(selector)) this.rects.set(selector, rect(part)); } for (const [key, found] of Object.entries(params?.passages ?? {})) { if (this.#passages.some((watched) => watched.key === key)) this.passages.set(key, lines(found)); } return; case 'albedo/selected': return this.#selected(params); default: // tools/call among them: a page cannot reach the agent's tools. if (id !== undefined) this.#fail(id, -32601, `${method} is not available to an artifact`); } } // The person selected text in the page (or let go of it), which a note can // be made for. The page says only what and where; the window is ours. #selected(params: any) { const window = [...pageHosts].find(([, host]) => host === this)?.[0]; const found = passage({ ...params, exact: params?.text }); const spans = lines(params?.rects); if (!window || !found || !selector(params?.selector) || !spans?.length) { if (selection.current?.page === this) selection.current = null; return; } selection.current = { target: { kind: 'passage', window, selector: params.selector, version: this.version, ...found }, page: this, lines: spans }; } /** The theme and colours, for a page that has started or when they change. */ context() { return { theme: this.page().theme, styles: { variables: themeVariables() } }; } notify(method: string, params: unknown) { this.frame()?.contentWindow?.postMessage({ jsonrpc: '2.0', method, params }, '*'); } /** Sends what the guard held, as though the person had asked for each. */ async release() { const held = this.held; this.held = []; for (const text of held) await this.say(text); } /** Posts to the chat as the person, headed with the artifact it came from. */ say(text: string) { const { title, version, session } = this.page(); if (!session) return Promise.reject(new Error('the chat this artifact came from is gone')); return sendMessage(session, uuidv7(), `From the artifact “${title}” (v${version}):\n\n${text}`); } discard() { this.held = []; } async #message(id: string | number | undefined, params: any) { const text = Array.isArray(params?.content) ? params.content.filter((part: any) => part?.type === 'text' && typeof part.text === 'string').map((part: any) => part.text).join('\n') : ''; if (!text.trim()) return this.#fail(id, -32602, 'a message needs text'); if (!this.page().session) return this.#fail(id, -32002, 'the chat this artifact came from is gone'); if (!params?.gesture) { const now = Date.now(); this.#unprompted = this.#unprompted.filter((at) => now - at < 60_000); if (this.#unprompted.length >= UNPROMPTED_PER_MINUTE) { this.held = [...this.held, text]; return this.#fail(id, -32001, 'held: this page sent several messages by itself, so albedo asks the person first'); } this.#unprompted.push(now); } try { await this.say(text); this.#answer(id, {}); } catch (error) { this.#fail(id, -32003, String((error as Error).message ?? error)); } } // A pinch over the page zooms the canvas, as it does anywhere else: the // page passes it on, and it is replayed on the frame, where the canvas hears it. #wheel(params: any) { const frame = this.frame(); if (!frame || typeof params?.deltaY !== 'number') return; const rect = frame.getBoundingClientRect(); const scale = frame.clientWidth ? rect.width / frame.clientWidth : 1; frame.dispatchEvent( new WheelEvent('wheel', { deltaX: params.deltaX ?? 0, deltaY: params.deltaY, deltaMode: params.deltaMode ?? 0, ctrlKey: true, clientX: rect.left + (params.x ?? 0) * scale, clientY: rect.top + (params.y ?? 0) * scale, bubbles: true, cancelable: true, }), ); } #answer(id: string | number | undefined, result: unknown) { if (id !== undefined) this.frame()?.contentWindow?.postMessage({ jsonrpc: '2.0', id, result }, '*'); } #fail(id: string | number | undefined, code: number, message: string) { if (id !== undefined) this.frame()?.contentWindow?.postMessage({ jsonrpc: '2.0', id, error: { code, message } }, '*'); } }