web client #
web/ is the browser client: a canvas (Svelte 5, Svelte Flow) where sessions open as panels. It is a client like the TUI, not an extension; it reads the same daemon HTTP API.
albedo web (cli/internal/cli/web.go) finds or starts the daemon, serves web/dist (or ALBEDO_WEB_DIST, which the Nix package sets to its web build, .#albedo-web: buildNpmPackage over web/, so bump npmDepsHash in nix/packages/web.nix when package-lock.json changes) on 127.0.0.1:4317 (--port, 0 for any) and, when this machine is on a tailnet, on its tailnet address too (below), prints a launch link and opens it unless --no-open or ALBEDO_NO_BROWSER is set, then returns after the detached server is ready. Repeating the command reuses the server and link; changing the listen options requires --stop first. --stop stops it, --new-link replaces its sign-in key and signs existing browsers out, and --foreground runs the server in the calling terminal until interrupted. The detached process is not supervised: it does not restart after a crash or reboot. cli/internal/webui owns the boundary:
- Each browser tab multiplexes its session panels and root-list follow over one WebSocket at
/api/events, avoiding the HTTP/1.1 six-connection SSE limit across tabs.stream.tssends new follows immediately on an open socket, preserves each session cursor independently, and resubscribes an ended follow. Unsubscribe sends a stop only for a follow actually sent. Hidden tabs stop chat follows but keep one root-list follow for completion alerts; without that background follow they close pending and open sockets and reopen on return. Stale socket callbacks cannot revive them. Older daemons fall back to the existing SSE routes, retaining their connection budget and visibility cancellation. Both the Go proxy and Vite forward WebSocket upgrades with the daemon bearer token.npm testinweb/exercises the stream lifecycle with mocked browser transport and timers. - The daemon token never reaches the browser. The page calls
/api/...on its own origin; the proxy strips/api, dropsOrigin,CookieandReferer, and addsAuthorization: Bearer <token>. Session streams pass through unbuffered. - The launch key persists in
$ALBEDO_HOME/web-keyacross stops and restarts./?key=<key>sets anHttpOnly,SameSite=Strictcookie that lasts a year and redirects to/;/apiwithout that cookie answers401 web_signed_out.--new-linkrevokes the previous key and cookies.--tailnet-authmakes the tailnet the sign-in: a request from100.64.0.0/10, or from the loopbacktailscale serveproxy that terminates its TLS, gets the cookie without a link (Server.TrustTailnet,Server.fromTailnet). The cookie still lasts a year, so a tailnet browser signs in once and keeps the session. This trusts every device on the tailnet with the daemon's full control; leave it off unless that is the boundary. It is recorded inweb.json(tailnet_auth) like the other flags. - Requests whose
Hostis not127.0.0.1,localhost, a shared address, an answered tailnet name or the published origin's host are refused (DNS rebinding), and so is anOriginthat is neither the request host's own, whatever scheme it arrived with (a TLS terminator makes the page https while the server speaks http), nor the published origin (Server.ownOrigin). The Origin comparison loses the default port on both sides, soHost: name:443andOrigin: https://nameare the same origin; any other port still tells two pages apart. --allow <network>(repeatable; CIDR, or an IPv4 address whose trailing zero octets stand for any, so10.0.0.0is/8and100.64.0.0is/16;webui.ParseNetwork) shares it with other devices: it listens on0.0.0.0, answers only on this machine's addresses inside an allowed network (also accepted asHost) and only to clients from one or from loopback (403otherwise), and prints a launch link per address. Devices still sign in with the key.--savestores the given networks in the daemon'sui.web_allowsetting, which a lateralbedo webwithout--allowuses;--savealone clears it.- On a machine signed in to Tailscale (
tailscale status --jsonreportsRunning;webui.CurrentTailnet),albedo webwith no--allowand nothing saved shares to100.64.0.0/10(webui.TailscaleNetwork) as if it had been given, andServer.Answeralso accepts the machine's MagicDNS name (full and short; the suffix is the tailnet's own, never assumed to bets.net) asHost, case-insensitively. It printson your tailnet: <full name>beside the per-address links. When stdin and stdout are a terminal it then draws a QR code (cli/internal/qr: a small in-repo encoder, byte mode, level L, versions 1-10, half-block rendering in explicit black on white) of the first link another device can reach: the tailnet name, else the first shared address; a loopback-only run prints none. Tailnet traffic is authenticated device to device and the launch key is still required. Without a tailscale CLI or a running tailnet it is loopback-only as before.--allowor a savedui.web_allowreplaces the default;--local(exclusive with--allow, ignores the saved networks) serves this machine only.--allowtakes networks, not device names: a device's own address is never one of this machine's. --public-url https://albedo.example.ts.netnames the origin a proxy in front serves this listener through: the printed link and QR carry it instead of loopback's http, andServer.Publishanswers its host and accepts it as the page's ownOrigin, so a proxy may pass the public name or loopback asHost(webui.ParseOriginlower-cases the host, leaves out a default port, and refuses a path, query or fragment).--tailnet-httpsgets that origin from Tailscale:webui.Tailnet.ServeHTTPSfirst requiresCertDomainsintailscale status --jsonto cover the machine's MagicDNS name (on Tailscale, HTTPS certificates in the admin console; on Headscale,dns.certificates), and refuses before running anyservecommand otherwise.Serve.Claimthen pointstailscale serve's https:443 root at this listener, or reuses a root already pointing here, readingtailscale serve status --jsonrather than its text; it refuses a root that publishes anything else or that Funnel opens to the internet, and removes a handler it created but could not confirm. The command that set the handler removes it on Ctrl-C, a failed start and--stop, only while it still points at this listener (Serve.Release). Each tailscale command has 5 s; a background start or stop gets 30 s (webGrace), both ending the server with SIGTERM so its handler comes down before a SIGKILL. Both flags are recorded inweb.json(public,tailnet_https), so a lateralbedo webreprints the same link and--new-linkresumes them.test/e2e/web_test.pydrives this through a faketailscaleonPATH.- A transport failure or a 401 from the daemon triggers
Connection.Refresh, so a restarted daemon is found on the next request. /assets/*(content-hashed by Vite) is cached as immutable; everything else isno-cache./frame/...forwards artifact files to the daemon without requiring the web sign-in cookie. It permitsGET/HEAD, andPOSTonly to.albedo/stateand.albedo/errors, which it forwards, and.albedo/message, which it answers itself (message.go): a page in a tab of its own posts its chat messages there, and albedo web sends them to the artifact's session after checking the key and the loop guard (robot-docs/artifacts.md). TheGET .albedo/versionlong-poll alatest/tab waits on passes like any file, without a timeout. It strips browserCookie,OriginandReferer, adds the daemon bearer token, streams the response, and applies the artifact sandbox/CORS/referrer/nosniff headers to every response.- While serving, the command atomically owns
$ALBEDO_HOME/web.json(pid, actualport,url,names,addresses,allow,local,public,tailnet_https,tailnet_auth). A second live server leaves the existing manifest alone and removes only a manifest that still names its own pid. Sharing to a network (the tailnet by default) listens on every address, which binds beside a server already on127.0.0.1(Go setsSO_REUSEADDR) while that one keeps answering loopback, so before listening it claims127.0.0.1:<port>once and refuses to start when another server holds it.
In development, npm run dev in web/ proxies /api the same way from $ALBEDO_HOME/daemon.json, read once at start (only when serving, so a build needs no daemon), dropping Origin and Cookie. WebSocket upgrades also drop Referer and require the page's own origin when an Origin header is present. It listens on the local network, open, so other devices at home can try the client; the Host it answers is limited to the machine's own name (bare and .local) and Tailscale's *.ts.net, since the proxy holds the token and a rebound name must not reach it.
Background alerts (attention.ts, notifications.svelte.ts, sessions.svelte.ts): the tab title counts distinct watched sessions that finished a turn, acquired a blocking reason, or received agent mail while the tab was hidden or unfocused; the favicon gets a small dot. A watched session is pinned, on any open board, or the phone's current chat, excluding archived sessions. Root-list activity supplies busy-to-idle transitions; a changed latest-answer id also catches a turn missed while disconnected. Mail marks the watched recipient, not the sender: task dispatch and system event mail do not count. Unwatched child turns stay quiet. The existing roots collection supplies these events; no per-child follows or daemon changes are needed. A closed board alone does not subscribe its sessions.
Returning to the tab (focus or becoming visible) clears the count and closes its system notifications. The settings switch asks for Notification permission only on a click and saves its opt-in in this browser, off by default. Denial, unavailable APIs and notification construction failures leave tab counts working. System notifications appear only while hidden, once per unread session, contain the session name and event kind (not message contents), and focus the tab and reveal its chat on click, reusing an open board before adding a window. Hidden tabs keep only the collection follow, including SSE fallback; chat/history reads still pause. If that follow is offline, the list is retried every 30 seconds. Browsers may suspend background tabs, so these are best-effort alerts, not push notifications. npm test covers counting, duplicate mail, focus clearing, missed transitions and the background transport lifecycle.
Words: a board is a named canvas of panels, and the open boards are the tabs along the top (boards.svelte.ts). A board holds sessions from any folders. Workspace keeps its albedo meaning, a session's folder, and the sidebar shows each session's folder under its name. Its sections are headed as the terminal heads them, the label in the text colour, its count faint, then a thin rule to the edge (the one place lines separate, as sectionRule does in the TUI); its rows alternate as the phone's list does, every other row in a section on --rail-stripe (the phone's step from its ground, taken from the rail's, about 1.06:1), hover lays a step over a row's own tone, and a session open on the board sits on --open-tint, the rail with a flat step of the accent (12% in light, 18% in dark), opaque so the stripes don't show through: a hue over the stripes' tones, under hover's step, and open rows that meet square their corners so a run of them reads as one block. [ (shown on the sidebar's toggle, as search shows ⌘K; those key chips sit a 12% step of ink above their button) hides or shows the sidebar; hidden, it pops out floating over the canvas, without animation, as soon as the pointer is within 20 px of the canvas's edge on its side, or further out the faster it is heading there (15 px more per px/ms, up to 50 px), so a flick toward the edge opens it early (App.svelte, watched from the canvas so the edge still pans). It stays while the pointer is on it or within 48 px of it, the top bar above it included, or while its filter has focus, and goes 100 ms after the pointer is clearly elsewhere or leaves the page; / brings it out the same way with the filter focused (Escape clears it and puts it away), and only focuses the filter when the sidebar is shown. + makes a new board; double-click or F2 renames one; closing a tab keeps the board, which the palette reopens (Open board: <name>); Delete board removes it. The last tab cannot be closed, so there is always a canvas.
A board is an open canvas. Windows sit anywhere on it; the view pans freely and zooms out to an overview. Svelte Flow draws, drags and resizes the windows; board.svelte.ts places new ones (right of the selected window, top-aligned, stepping past overlaps) and owns the viewport. The wheel is handled in Canvas.svelte rather than by Svelte Flow, because windows and canvas share the trackpad: a pinch (ctrl-wheel) zooms around the pointer anywhere, a mouse wheel's notch with ctrl or ⌘ by one 10% step (a notch is a jump of 50 px or more, or any line-mode delta, where a pinch moves a few pixels at a time), an up-down scroll over a window that can scroll scrolls it while the zoom is readable (60% and up), so does a sideways scroll over anything under the pointer that scrolls sideways (a diff's code inside Pierre's shadow root, a wide table; found through composedPath()), and every other scroll pans. Dragging empty canvas pans; holding Space (unless a text field has focus) or the middle button drags the canvas from anywhere, over windows too, taking the pointer before Svelte Flow or the window sees it; holding Alt (Option), a drag anywhere on a window or note moves it, not only by its title bar, without selecting text, and the click that ends it is swallowed (Option-click would download a link), and Space with a double-click anywhere glides the view there, centred at 90% (Board.centreOn, 300 ms from the middle of the pane up to 500 ms from a corner), where a plain double-click on empty canvas leaves a note. + and − zoom in and out by 5 points (the buttons too), ⇧1 fits all windows, ⇧0 returns to 100%, M shows an overview map, ⌥←/⌥→ moves between windows. The view controls (zoom, fit, map) in the bottom-right corner, under the overview map when it shows (as wide as the controls, measured, and showing and fading with them), and key hints in the bottom-left as the terminal's footer lists them (spc and a grab to pan, ⌥ and a move to move a window, cmd and a scroll to zoom, ⇧1, ⌥←→, spc and two clicks to zoom at a spot, two clicks for a note; the mouse gestures are Lucide's icons, drawn in Gesture.svelte, to keep them short; keys in the text colour, what they do muted, each pair on a surface of its own with a small gap between), fade in while the view moves and fade out 4 s after it stops; hovering or focusing them keeps them, and once faded they let clicks through to the windows beneath. What a window shows is a view, each framed by nodes/WindowFrame.svelte: a session's chat, or a review of one turn's changes; files, terminals and agent-made pages are meant to follow. A view opened from a window names it as its parent, opens beside it, and the canvas draws a faint dashed line between them (the board derives Svelte Flow edges from those parents; every window carries invisible handles for them).
The review (nodes/ReviewNode.svelte, review.ts) opens from "review N files +a −r" on a turn's sign-off, or from the +a −r on any work line, which also scrolls it to the first file that stretch of work changed (data.jump). It needs no daemon support beyond history: every tool call's trace records each file it changed, with the unified diff (changes[].diff, or reason when the file was binary, unreadable or too large). Writes the harness makes for itself stay out of it (albedo_trace.unobserved()): the browser plugin's profile lock and marker and its auto-named screenshots (in the cache, see kernel) among them; a screenshot saved to a path the cell chose is still the cell's change. The window keeps the session, the turn and the turn's first history position, reads that turn's entries (after=, until the next turn starts), hydrates their traces, and shows the files with their counts, then each file's diffs in order. Diffs are drawn by @pierre/diffs (Shiki highlighting, pierre-dark/pierre-light tokens over the canvas's own ground and diff colours; views/DiffView.svelte), loaded with the first review so the rest of the client stays small. The margin button beside a line (drag for several) leaves a note on the canvas beside the window, tied to those lines; "send review" sends every unsent note tied into the review as one message, after an optional overall line. While the session works, the review reads what came after the entries it has every 2.5 s, and once more when it stops (readTurn without seek ends at the first entry of another turn, so an older turn's review costs one page a read); results already filled in are kept, and a file whose changes grew glows in the index for a moment.
The terminal (nodes/TerminalNode.svelte, views/TerminalView.svelte, jobs.svelte.ts) shows one background job's output, read-only. A terminal icon in a chat's title bar (JobsButton.svelte, on the phone above the composer, shown once the session has had a job; it and the artifacts icon share MenuButton.svelte) counts the running jobs and lists them, finished ones with their exit codes while the kernel keeps their output; the list polls GET /extensions/run/sessions/{id}/jobs/recent every 2 s while a job runs, 6 s otherwise. Choosing one opens a window beside the chat, tied to it like a review. The window opens on the last page of .../jobs/{job}/output and follows next_offset every 700 ms while the job runs (once more after it ends), keeps at most 400,000 characters, drops colour codes and shows a \r-redrawn line as its last draw. Its stop button asks twice, then posts .../jobs/{job}/stop. A job the kernel no longer has (a restarted kernel) says so. On a phone the same view opens in the chat's dock, above the composer.
The trace (nodes/TraceNode.svelte, views/TraceView.svelte, trace.ts, TraceTie.svelte, tracelink.svelte.ts) shows one task as albedo ran it: "trace" on the sign-off of a turn that ran tools opens it beside the chat, tied to it like a review. A task is that turn and the turns after it until the person types again (mail from a child, continuations), so a parent's wait for its agents and their replies fall inside it. A trace opened on the session's newest task follows the session instead: every later turn is added as it begins, each message the person sent marked on the axis ("you"), and it reads again whenever the session's phase changes as well as every 3 s while anything in it works, never while the tab is hidden, and every 5 s while a read fails. Its axis runs ahead of now (a dashed line), by a quarter of the task or at least 30 s, and grows only when now reaches the end, so the calls already drawn hold still under the pointer between reads. "earlier turn" adds the turn before the earliest shown (turnBefore walks history back to the person's previous message; the count is saved on the window as earlier), read in place so the view keeps its zoom.
The trace is read in parallel where it can be: agents, jobs and model calls start with its history, and the session's own lane shows before its agents are read. Each window keeps per-lane history positions, request sequences and known children. Live reads append new entries and calls, close pending tools, and reuse completed child lanes until their activity changes; running jobs and child summaries are reconciled. A followed child keeps growing while its parent waits. Hydrated inputs and traces are kept, with a tool’s later result fetched separately. Earlier-turn changes and failed reads discard the cursors. Initial model-call reads use since_ms to skip requests that finished before the task; a daemon rejecting it with 400 is detected once, then uses the older parallel single-row probing search. Later reads use request sequences on either daemon. It animates only transform and opacity, and nothing animates while it is out of view.
Rows come from three places: model calls from the session's request records (GET /sessions/{id}/context?view=requests, oldest first; since_ms finds the task’s first records, with single-row probes only on older daemons), tool calls from the transcript (a call's entry to its result's; input a history page only referenced is read afterwards in one history/content walk per agent), and agents from GET /sessions?parent_id=, three deep, kept when they were alive during the task. Each agent is one lane: its calls never overlap, so model calls (grey, the wait for the first byte lighter) and tool calls (the lane's hue, which keeps clear of red; failures are red) take turns along one strip, and a gap of 1.5 s or more while an agent below works is hatched as waiting. Under the strip, each tool call has one chip naming the most telling thing it did, read from its trace (a web search, then an MCP call, the computer, an artifact, an edit, a command, a search, a read, a listing, a display; the first line of its code when it did none of them), with the rest counted ("+3") and listed on hover and in its drawer. Calls closer than a readable chip (64 px) share one cluster chip that counts their kinds (as many as fit) and zooms to its stretch when pressed; clusters are worked out again as the zoom changes, and no chip runs past the next, so none overlaps. Jobs are packed by where they end on screen, at least 18 px wide, for the same reason. A fold's idle label stays off the ticks, and gives way to the person's "you" when their message ended it. Under the chips, the lane's background jobs (GET /extensions/run/sessions/{id}/jobs/recent, kept only while the kernel keeps them) on the same clock, packed into as few tracks as keep them apart; tracks past the first fold into a thin band of ticks, and pointing at them floats them out over the lanes below, so opening them never moves the layout. A running job shimmers, one that failed or timed out is red, one started as a service is dashed, and a click opens its terminal beside the trace. Jobs never keep idle time from folding. The gutter gives each lane's model and how much context its last call sent, and what share came from the cache; a compaction summary's call is drawn in the accent. Calls are often a few pixels wide, so the strip is taller than its bars and finds the call itself (callAt): the nearest within 10 px, a tool call close by beating the model call under the pointer. Spawn arrows leave the parent's tool call running when the child was created; reply arrows leave the child's last step before its mail landed. Above the lanes, one bar says where the task's own time went (spent): thinking, running code, waiting on agents, waiting on the person (an idle stretch their message ended), and the gaps between steps, with the duration, tokens, calls and failures beside it; all of it follows the playhead.
A press on a call opens a drawer under its lane: what it did (each edit opens the review of its turn at that file, through onreview), its code and output (read then, in one batch), or a model call's first byte, tokens sent, cache share and tokens written; ← and → step along the lane, Esc closes. Hovering a tool call in either the trace or the chat lights it in both (traceLink.entry, keyed by the chat's entry: the result once there is one), and TraceTie draws a line between them over the canvas, pinned to the chat's edge when the call is scrolled out of view; opening one in the trace sets traceLink.reveal, which opens its stretch in the chat and scrolls it there. Any stretch of 20 s or more with nothing running is folded into a 2.5 s break labelled with how long it was idle (clockOf, a polylinear d3 scale); ticks are round elapsed times within each busy stretch. Zooming, panning and replaying only move CSS variables (--w the axis width, --left the pan, --t the playhead), so none re-renders a bar: a pinch zooms at the pointer, a sideways swipe pans (the canvas leaves both to anything inside .zooms-itself while the window is readable), a press on the axis scrubs, a double-click on it or "fit" shows the whole task, a double-click on a call zooms to it, and the replay plays any task in about 12 s. Search matches a call's name, the first 4000 characters of its input and its error; Enter opens each match in turn. prefers-reduced-motion drops every animation but the replay.
The artifact (nodes/ArtifactNode.svelte, views/ArtifactView.svelte, artifacts.svelte.ts; robot-docs/artifacts.md) is a page the agent made, live in a sandboxed <iframe> (no allow-same-origin) on /frame/{id}.{key}/{version}/{entry}. The chat learns of one from the artifact activity in a cell's trace: a line under the turn's summary (made counter, updated counter v2) opens it, and one that arrives while the chat is open (Transcript.made, never history read on opening) opens a window beside the chat, tied to it like a review; the view moves only when that chat is the selected window, and then shows both (Board.revealTogether, zooming out no further than 60%). An open window follows each new version in place unless the version menu pinned it; a window the reader closed stays closed. Its new-tab button opens /frame/{id}.{key}/latest/ while it follows, a tab that reloads itself as the agent revises the page (robot-docs/artifacts.md, the bridge), and the pinned version's own address while pinned. The board saves the artifact's id, its session, the pinned version and the newest version heard of, never the page. The window answers the page's bridge (PageHost): the theme and the client's colours resolved to plain values (themeVariables, sent again when the theme or palette changes), the page's messages to the chat headed From the artifact “<title>” (v<n>):, with the loop guard (five a minute without a click behind each; the rest wait behind send and discard), failures shown under the page with "ask albedo to fix it", and a pinch over the page replayed on the frame so it zooms the canvas. While the canvas is being panned or a window carried, or below 60% zoom, the frame lets the pointer through. On the canvas the page is zoomed, not scaled (ArtifactView): the frame gets CSS zoom at the canvas's zoom and scale(1/zoom), so its own document lays out at the window's width and draws at the zoom's resolution while its surface reaches the screen untransformed. Chrome draws a scaled frame from another process (the sandboxed page always is one) from a stale or low-resolution raster while it scrolls and after a pinch, then sharpens a moment later; zoomed, there is no scaled surface to resample. Each zoom change re-rasters the page, so mid-zoom the frame catches up only when its zoom is more than a tenth off the canvas's (DRIFT), and takes it exactly once the canvas holds still for 40 ms (SETTLE); the page is scaled the rest of the way in between. The page sees a devicePixelRatio of the screen's times the zoom; PageHost maps points through the frame's on-screen size against its clientWidth, which already covers it. On a phone the artifact opens in the chat's dock, sized to its page. The palette finds artifacts by title, and open an artifact… and delete an artifact… list every one, from deleted sessions too. npm run dev proxies /frame/ like albedo web, with the same headers.
Design rules the client keeps (from the design pass): surfaces separate by a step in tone, not by lines; only what floats (windows, the palette, the view controls, the overview map) casts a shadow; one accent, albedo's mauve by default and editable, used for focus and selection; no status dots; monospace only for code and tool commands.
The client shares the terminal's voice. moods.ts is a port of cli/internal/tui/moods.go (its pools, their order and the seeded FNV-1a pick), so a finished turn signs off with the same kaomoji in both clients: its seed is the turn's last entry time in milliseconds, and its class comes from how long it took. A busy session shows an animated face for what it is doing, read from its phase (preparing, generating, running a tool, compacting, interrupting; only idle is at rest) and the tail of its activity, on one shared 500 ms clock for every face on the page (live.svelte.ts). Every frame of the animation sits invisibly under the current one, so a face keeps its width and nothing beside it moves. burst.ts writes a turn's thinking and tool calls as the terminal's one past-tense line (thought 12s · read tui/{chat,moods}.go · ran go test ×2 +12 −3), from each call's trace, fetched when the line scrolls into view. Speakers are named as in the terminal: "albedo" in mauve, "you" in teal, with "2h later" after a pause of 30 minutes or more. Copy is lowercase, and empty and error states carry a face. The canvas has sparse dots (3 px every 36 px, about 1.8:1 on the ground in both themes) that merge rather than fade as the view zooms out (Dots.svelte): between 100% and 50% every other dot dissolves into its neighbours, leaving the grid as it looked at 100%, and so on a level up, so the dots on screen stay 18 to 36 px apart and 3 px across at any zoom.
Markdown (markdown.ts) also understands maths and diagrams: $…$, \(…\) and $$…$$ / \[…\] (a dollar amount like "$5 or $10" stays prose: the opening $ must touch its text and no digit may follow the closing one) and ```mermaid blocks. They render as placeholders carrying their source, and enhance.ts draws them after the message mounts: KaTeX for maths (trust: false), Pele (pele.run) for diagrams, themed with --pele-* from the canvas tokens, its SVG kept to drawing (no scripts, foreign objects, event attributes or outside links). Both load only when a message needs them.
Notes (nodes/NoteNode.svelte, anchors.ts, notes.ts, quote.ts, Tethers.svelte) are small text boxes on the canvas, saved with the board. Double-click empty canvas or press N for a loose one; the composer's pop-out button moves the draft into a note tied to its chat, for writing at length; selecting text in a window (a message, a step's output, anything of the client's own; not yet a diff, whose text is in Pierre's shadow root) floats a "note" button just under the end of the selection (kept on the canvas, so a window at the edge of the view still shows it), which leaves a note beside the window tied to exactly that text. Dragging a note's grip onto something ties the note to it: the selected text, when the pointer is on it; a window; in a chat, a step (data-entry), or in a reply the block under the pointer (a paragraph, list item, code block, heading, quote or table row), with the speaker's name standing for the whole message; a line in a diff (found by the diff's data-diff key and the row's number inside Pierre's shadow root); or a part or a passage of an artifact's page (asked of the page through its bridge, since it is another document: pageHosts in artifacts.svelte.ts holds each artifact window's bridge, the frame lets the pointer through while a tie is drawn, and the part is kept as a CSS selector, the version and a quote; see robot-docs/artifacts.md); dropping it on empty canvas sets it loose. A passage of text, the client's own or a page's, is kept as the W3C TextQuoteSelector keeps one (quote.ts): its words (up to 2,000 characters) and 32 characters either side, inside the entry it was in, and found again by those after the window redraws, the best match of its context where the words appear more than once; a block is kept the same way. Old saved boards load unchanged. Until a chat's first page is in and scrolled to its end, the thread stays hidden behind a skeleton shaped like the end of a typical chat (measured at phone width: a work line, a message, more work, the reply's last lines and its sign-off), so it never shows its top and then jumps. A chat follows its end as it grows unless the reader has scrolled up (more than 40 px from it); then a pill floats over the composer (on a phone, above the drawer's handle): "see new messages" once the end has changed since they left it (a new last block, or more of a live reply; older pages loading above do not count), "back to latest" until then, and it scrolls back down. Tables in messages scroll sideways instead of squeezing their columns. A review names each file by its last two path segments (…/chat/Chat.svelte), the full path as its title.
While the grip is dragged, what it would land on is outlined (pick in anchors.ts); the drag follows the pointer on the whole document, so a note redrawn mid-drag, or dismissed, never leaves the tie or its outline behind. The target and a quote of it (the diff lines, the passage, or a message's first 600 characters) are kept on the note, so what it was about survives scrolling and closing. A line runs from each tied note to its target, and a target that is a passage (diff lines, a message, a step, selected text, a part of a page) is tinted while on screen: the client's own text under its words with the CSS Custom Highlight API (albedo-note-normal, -lit, -sent), a page's passage line by line over the frame, and a passage that can no longer be found as the block it was in; the note in hand, selected or under the pointer, draws its line and tint strongest. The line is measured in screen space every frame while any note is tied, held inside the window and whatever scrolls it. A note layers like any window: the selected window is on top, so a note just made starts above the rest, and picking a chat or an artifact brings it over the note.
Notes are drafts, as a pending review's comments are: every written, unsent note tied into a session, to its chat or any of its windows (Board.sessionOf), goes with that chat's next message: the chat's own notes first, then each window's in board order, each from the top (drafts in notes.ts). The notes are not drawn in the composer, since each is already tied to its window by a line; the send button widens to a note icon and how many go with the message. A note's footer says what it will do, with next message or on its own, and a click on it switches (alone, saved with the board; boards saved with held read as alone): a note on its own is sent only by its own button. Sending the composer sends what was typed as the lead and every note that goes with it after it, as one message, through the chat's own send (so it shows as pending and can be retried); with notes going, an empty field sends just them. A note also sends alone (⌘↵ or its send button), a review's notes with "send review" (they go with the next message too, and are done once sent), and the palette's "send all notes" sends every session's notes, except those on their own, as one message each. Each note quotes only what it was tied to (a note tied to a chat window itself is just a message; one tied to a part or passage of a page names the artifact, the version and the selector). Sent notes stay on the board, quieter, until dismissed.
On a narrow screen (760 px or less) the client is a plain chat app unless settings ask for the canvas (canvasOnPhone): layout.svelte.ts and Phone.svelte show the session list, then one session's chat filling the screen, with a back button and the browser's back between them (history.pushState). There a review opens inside the chat, under the line that asked for it (views/ReviewView.svelte, the review window's body too), and notes and pop-out are off. The phone's session list and drawer are one picker (SessionPicker.svelte): up to six tiles (pinned, then sessions opened three or more times, most first, then today's), every other session as a list under them (three dots between the two, and every other row takes a softer step of the tiles' tone, so the stripes don't read as tiles), and a search field with a new-session button docked at the bottom on the ground's tone, within thumb reach; while searching it is all list. The phone's chat reads at 16 px unscaled with narrow margins, its composer floats over the thread (which scrolls on under it and ends clear of it), a chat has no bar and takes no gesture from the browser: a handle floating above the composer raises the session picker as a drawer over the chat. The drawer and the composer are one surface: the handle is the sheet's top edge, resting there when closed, and the sheet stands on the composer's bottom edge rather than the page's, so nothing of it reaches under what floats there (Safari's toolbar) and its search lands where the composer was. Dragging it up (or a quick swipe up from the composer itself, unless it would scroll the draft or follows a hold) raises the sheet with the finger while the composer fades into the search bar pinned in its place; dragging it down from its grip, or from anywhere while its list is at the top, lowers it. Let go and it settles open when past half (lowering: while still two thirds up) or flicked up, and otherwise sinks back into the composer; settling tweens the same number a finger moves, so the sheet, the composer's fade and the scrim stay one motion. A tap on the handle opens it, and a tap on its grip or above it, or choosing a session, closes it, and the browser's back still returns to the list, and the composer is one row (picture, field, stop, send).
New session (NewSession.svelte; the + beside the sidebar's filter, the phone list and ⌘K): pick the folder from the folders sessions have used (GET /workspaces, ranked as you type), type a path, or browse to one (GET /workspaces?location=); then the provider profile (GET /settings?group=providers, starting on the default), its model (GET /models?provider_profile=) and effort, starting on the profile's own. Start is PUT /sessions/{uuidv7} with If-None-Match: * and {kind: "new", workspace, provider_profile, model, effort}, and the session opens.
Model picker (chat/ModelPicker.svelte, sharing ModelChoice.svelte with new session): the composer shows the session's model and effort as a chip; it opens a pane to switch the provider profile, model and effort, which reads the session's configuration for its validator and sends PATCH /sessions/{id}?view=configuration as a merge patch with If-Match. It applies from the next turn.
Context meter (chat/ContextMeter.svelte, maths in context.ts): beside the model chip, a ring and the share of the window the context fills ("38%"), muted, in the danger colour from 90 % where compaction starts; hovering or focusing it says "76k of 200k tokens · 70k cached". It shows nothing before the session's first usage, and "—" when that usage came from a model other than the session's now. Usage comes from the chat's own session stream (Transcript.usage: the reset snapshot's usage, then usage events), so nothing polls. The window is GET /sessions/{id}/context-window (effective_context_tokens), read once per model the usage names. The cached count follows cache_fade as the terminal footer's does and is redrawn at each step's time, not on an interval. It rides in the same snippet as the model chip, so the phone's composer has it and an inline chat does not. Tested by scripts/context.test.mjs.
Slash commands (commands.ts): the chat reads the session's commands from GET /sessions/{id}/catalog?kind=commands (only those a human may call; the list is empty until the session's runtime has loaded once). Typing / in the composer lists them, filtered as you type (arrows, Tab or Enter to complete, Escape to clear). A line starting with a command runs it: arguments are read the way the terminal reads them (words in order, the last taking the rest, or a JSON object), and one name that is both a read and a change picks the read when nothing follows it. Skills and command inputs are sent as {kind: "command"} inputs; reads and changes are the daemon operations the catalogue declares, with path, query, headers and body bound from the session record, the arguments or literals, and their answer shows above the composer until dismissed.
The (?) between search and settings opens an about pane (About.svelte, under it): a one-line description (a placeholder for now), the main contributors with their Bluesky handles (tiles two to a row, an odd one centred under them), thanks to everyone else with a link to the full history on tangled, and the libraries the client is built on with their licences (pills in centred rows), linking THIRD_PARTY_NOTICES.md.
Settings (the sliders button at the top right, or ⌘,) is a non-modal pane over the board: the theme (follow the system, the default; light; dark), each mode's colours, the canvas dots, and which side the sessions sit on ("sessions on the right" mirrors the shell: the sidebar, its toggle and the brand on the right, search, about and settings on the left, and the about and settings panes open under their buttons; set on <html> as data-sidebar). A mode's colours are three seeds and a contrast, as in the Codex app: accent, background (the ground), foreground (the ink) as hex, with a native picker beside each, and contrast from 0 to 100; accent presets fill the accent for the mode being edited, and a theme copies and pastes as JSON (both modes, or one palette for the mode being edited). app.css derives every other token from the seeds with color-mix in OKLab (surfaces, chrome, fields, secondary text, hover, dots, shadow tint) and relative oklch() (the brand's second stop, and "you" opposite the accent), so any three colours make a whole theme; contrast scales how far each step goes. The colours are madoromi's (MPL-2.0, credited in the pane and in THIRD_PARTY_NOTICES.md): the defaults are its spring, haru, by day (#f6eff8 paper, #584a5c ink, #6928bd wisteria) and by night (#241c26, #e1d1e7, #ddb3ff); its four seasons set both modes at once, keeping each mode's contrast, and the accent presets are its spring inks. Preferences live in this browser (prefs.svelte.ts, albedo.prefs.v4, carrying the theme and dots over from earlier keys) and in an albedo_prefs cookie, which every port on the host shares, so the dev server and each albedo web port open the same way; the cookie wins when both exist, and albedo web drops it before forwarding to the daemon. Settings also take an interface and a code font by name (a comma adds fallbacks; each name is quoted and nothing that could end the value survives, see fontStack), set as --sans and --mono on <html> ahead of the default stacks; where the browser offers queryLocalFonts, the installed families are suggested. The resolved mode is data-theme on <html> and its seeds are inline custom properties there; a small script in index.html applies both before the first paint, and prefs follows system changes while the theme is system.
The chat view (chat/) reads GET /sessions/{id}/history (newest page, then before= as the reader scrolls up, and after= when the stream says something committed) and sends with PUT /sessions/{id}/inputs/{uuidv7}; while the session works, a stop button beside send (or Escape in an empty field) reads the session's run_id and input_order fresh and posts /sessions/{id}/interrupt, so a stop never reaches a later run, {kind: "message", text}; images attach by paste, drop or the composer's picture button (chat/attachments.ts, the terminal client's scheme: each image leaves an [Image #n] marker at the cursor, deleting the marker drops the image, sending renumbers the marked images 1..n in reading order and sends them base64 as images, at most 10); a user entry's images show above its bubble from their history references (/api/sessions/{id}/history/{entry}/image-n). a send stays on screen as pending until its input_id appears in history, and a failed one offers retry with the same id. Your newest message carries a receipt until a reply follows it, drawn as Signal's (chat/Receipt.svelte): a dashed ring while sending, a ring with a check once the daemon has accepted it (sent), two filled ones once its entry is in history (seen), which the daemon commits in the same transaction that takes it into the conversation, so the model has it (a message sent while albedo works stays sent until the loop takes it at its next step). Until it is seen the bubble stays faded. A waiting input is not listed anywhere, so after a reload it shows nothing until it commits. transcript.svelte.ts turns entries into blocks: user bubbles, assistant markdown, and each run of thinking and tool calls between messages folded into one "Worked" line (open while it is the live run). Referenced message text is fetched with the page; tool arguments and output only when their run is opened. Markdown is rendered by marked with raw HTML escaped and only http(s)/mailto links (markdown.ts), because transcripts carry model and tool text into a page that can reach the daemon. The session list (sessions.svelte.ts) follows the collection's stream (GET /sessions?scope=roots as server-sent events, followSessions in stream.ts): each activity event patches that session's status and activity in place, so the sidebar, window titles and faces change as the daemon does, and reset, overflow or invalidate reads the list again (once for a burst). While that stream is up the list is also read once a minute in case; while it is down, every 4 s. Both stop while the tab is hidden. A turn's sign-off row carries its actions, trace and copy as icons, shown while the pointer is anywhere over the turn's reply or on the row itself (found from the page when the pointer moves, hover in Chat.svelte), or always on a screen with no hover. What is typed in a composer is a draft kept by session (chat/drafts.ts), outside the composer, which goes with its window when another board is shown, so the draft is there when the board comes back. The text is also written to localStorage (albedo.draft.v1:<session>) once typing pauses for 300 ms, and on pagehide, so a reload keeps it; sending or emptying the field removes it, and one untouched for 30 days is dropped at the next load. The images it marks are not kept (they are too big for storage), so a reload drops them and their [Image #n] markers; switching boards keeps them.
Tool steps and burst clauses read the daemon's derived title from history entries, stream events, or tool progress, falling back to line 1 for older daemons or when no title is provided.
Each chat also follows the session's event stream (stream.ts: GET /sessions/{id}?tail=0 as server-sent events, read with fetch so it can resume with after_generation and after_seq, backing off from 0.5 s to 5 s). Thinking and reply deltas show as they are written, tool progress shows as a running step until its call commits, and status events drive the faces ahead of the session list. A reset carries a snapshot whose active_output and current_progress restore what was in flight. A committed event reads the new history first and then drops the live output it names, so a reply never blinks out between its last delta and its entry. While the stream is down the chat falls back to polling every 1.2 s while busy and 5 s otherwise; while it is up, every 20 s.
Boards live in the browser's localStorage: the list under albedo.boards.v1, and each board's windows (position, size, what they show) and viewport under albedo.board.v2:<id>. Only the active board is mounted; FlowBinder hands it the flow helpers, and anything asked of a hidden board (revealing a new window) runs when it is shown.
Wire types live in src/lib/api.gen.d.ts, generated from docs/openapi.yaml with the exact openapi-typescript dev dependency. Run npm run generate in web/ after changing the contract; npm run generate:check compares the output without writing or downloading dependencies. Version 7.13.0 accepts the canonical OpenAPI 3.2.1 document directly; no projection is needed. SSE item schemas and framing remain handwritten in stream.ts. api.ts, the command catalogue and tool traces use generated aliases; app-only projections (a profile's map key, normalized review changes) stay local. TypeScript 7 (the native compiler) is the tsc of this package (@typescript/native is an alias of typescript@7), and npm run check is svelte-check --tsgo, which type-checks .ts and .svelte with it. The package called typescript is Microsoft's @typescript/typescript6 alias, because svelte-check's source mapping and openapi-typescript import the old JavaScript compiler API that 7.0 does not ship; an overrides entry lets openapi-typescript take it despite its ^5.x peer range. Drop the alias when both tools support 7's API (TypeScript 7.1).
Checks: npm ci, then npm run generate:check, npm run check and npm run build in web/; go -C cli test ./internal/webui/ for the boundary. test.sh runs the offline generation check beside the Go API generation check, so install the web dependencies first. The dev shell supplies Node. The Svelte check and build are not in test.sh.
seeing the client #
A layout or styling change to the client is judged by looking at it, not by npm run check and not by the code. Start a private daemon (its own ALBEDO_HOME and an ALBEDO_DAEMON copy from test/snapshot-daemon.sh, as in AGENTS.md), npm run build in web/, run albedo web --no-open --port 0 with that ALBEDO_HOME (ALBEDO_WEB_DIST points it at another build), open the link it prints with the browser binding, and screenshot the states you changed:
- a desktop width, the canvas with the panels the change touches open;
- phone width (760 px or less), which is a different layout (
Phone.svelte), unlesscanvasOnPhoneis set; - light and dark (settings, theme), since the colours are derived from seeds.
npm run dev works too (it proxies /api from $ALBEDO_HOME/daemon.json). Screenshots the browser binding saves without a path go to $ALBEDO_HOME/cache/browser-screenshots/, not the checkout; page.screenshot() returns the path to read. Close the browser, the server and the daemon when you are done, and check ps for ones naming your home.