small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
110 kB · 154 lines
Markdown
at main

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. Without --save, 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.ts sends 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.
  • The daemon token never reaches the browser. The page calls /api/... on its own origin; the proxy strips /api, drops Origin, Cookie and Referer, and adds Authorization: Bearer <token>. Session streams pass through unbuffered.
  • The launch key persists in $ALBEDO_HOME/web-key across stops and restarts. /?key=<key> sets an HttpOnly, SameSite=Strict cookie that lasts 400 days (the longest browsers keep one), so a browser stays signed in after it closes, and redirects to /; /api without that cookie answers 401 web_signed_out. --new-link revokes the previous key and cookies.
  • Requests whose Host is not 127.0.0.1, localhost, a shared address, an answered tailnet name or the published origin's host are refused (DNS rebinding), and so is an Origin that 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, so Host: name:443 and Origin: https://name are 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, so 10.0.0.0 is /8 and 100.64.0.0 is /16; webui.ParseNetwork) shares it with other devices: it listens on 0.0.0.0, answers only on this machine's addresses inside an allowed network (also accepted as Host) and only to clients from one or from loopback (403 otherwise), and prints a launch link per address. Devices still sign in with the key. --save stores the given networks in the daemon's ui.web_allow setting and installs a user login service. It runs albedo web --foreground with the saved networks and port. --save alone stops the server, clears the saved networks, and removes the service. The service does not save --public-url or --tailnet-https; those combinations are rejected.
  • On a machine signed in to Tailscale (tailscale status --json reports Running; webui.CurrentTailnet), albedo web with no --allow and nothing saved shares to 100.64.0.0/10 (webui.TailscaleNetwork) as if it had been given, and Server.Answer also accepts the machine's MagicDNS name (full and short; the suffix is the tailnet's own, never assumed to be ts.net) as Host, case-insensitively. It prints on 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. --allow or a saved ui.web_allow replaces the default; --local (exclusive with --allow, ignores the saved networks) serves this machine only. --allow takes networks, not device names: a device's own address is never one of this machine's.
  • --public-url https://albedo.example.ts.net names the origin a proxy in front serves this listener through: the printed link and QR carry it instead of loopback's http, and Server.Publish answers its host and accepts it as the page's own Origin, so a proxy may pass the public name or loopback as Host (webui.ParseOrigin lower-cases the host, leaves out a default port, and refuses a path, query or fragment). --tailnet-https gets that origin from Tailscale: webui.Tailnet.ServeHTTPS first requires CertDomains in tailscale status --json to cover the machine's MagicDNS name (on Tailscale, HTTPS certificates in the admin console; on Headscale, dns.certificates), and refuses before running any serve command otherwise. Serve.Claim then points tailscale serve's https:443 root at this listener, or reuses a root already pointing here, reading tailscale serve status --json rather 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 in web.json (public, tailnet_https), so a later albedo web reprints the same link and --new-link resumes them. test/e2e/web_test.py drives this through a fake tailscale on PATH.
  • 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 is no-cache. The root HTML shell uses an ETag of its current bytes, not a filesystem modification date: packaged files share Nix’s epoch mtime across releases, so date-only validation could otherwise keep stale HTML pointing at removed bundles. Legacy If-Modified-Since requests receive current HTML; matching content ETags still receive 304. Reading the current shell also preserves in-place development rebuilds, and /index.html keeps its redirect to /.
  • /frame/... forwards artifact files to the daemon without requiring the web sign-in cookie. It permits GET/HEAD, and POST only to .albedo/state and .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). The GET .albedo/version long-poll a latest/ tab waits on passes like any file, without a timeout. It strips browser Cookie, Origin and Referer, 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, actual port, url, names, addresses, allow, local, public, tailnet_https). A manifest is live while its pid exists and its port takes the connection (webui.ManifestAlive): a server that answers late is busy, so --stop and opening an artifact still find it. A per-home process lock prevents foreground, detached, and supervised web servers from running together. Each server 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 on 127.0.0.1 (Go sets SO_REUSEADDR) while that one keeps answering loopback, so before listening it claims 127.0.0.1:<port> once and refuses to start when another server holds it.

cli/internal/cli/login_service.go owns the shared web/proxy login service. Each role and ALBEDO_HOME has a separate service identity. macOS uses a LaunchAgent in ~/Library/LaunchAgents, with RunAtLoad, KeepAlive, and logs at $ALBEDO_HOME/web.log. Linux uses a systemd user unit under $XDG_CONFIG_HOME/systemd/user (or ~/.config/systemd/user), enabled and started immediately. Without lingering, the command prints a loginctl enable-linger hint but does not change it. Lingering permits startup at boot and operation after logout. The service preserves the invoked executable path, including a moving Nix out-link; the package's compiled wrappers preserve that path through both wrapper layers. It retains ALBEDO_HOME, PATH, and explicit ALBEDO_ROOT, ALBEDO_DAEMON, or ALBEDO_WEB_DIST values. ALBEDO_DAEMON and ALBEDO_WEB_DIST values under /nix/store are omitted so the current package wrapper supplies fresh defaults at each start; non-store overrides are retained.

With a web login service installed, a bare albedo web starts or kickstarts it and prints the launch links. --stop stops it through the service manager, so supervision does not restart it immediately. The command says that it returns at the next login and gives albedo web --save as the command to forget it. --new-link stops the service, revokes the old key, and starts the service again. Other listen options require a --save update or removal of the service. ALBEDO_LAUNCHCTL and ALBEDO_SYSTEMCTL override the OS manager executables for tests.

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.

A human composer line beginning with ! submits the trimmed remainder as a command to the daemon shell-command resource; it is not sent as a model prompt. The web client never runs a local subprocess. The shell entry (chat/ShellCommand.svelte) is a dedicated, card-free transcript block marked “you · shell”, not a tool call: a quiet left margin mark keeps the command, compact status and output together. Idle shell outcomes sit after the model turn’s sign-off and do not extend its elapsed time; receipts delivered between model steps retain the surrounding turn’s identity rather than signing it off twice. A tooltip-only receipt icon beside the command header’s status/open/copy controls uses the ordinary message-receipt symbols: “Queued for agent” until the daemon acknowledges model delivery, then “Sent to agent”. It has an accessible status label; no separate delivery row is shown. Process completion/exit status remains independent. Idle completion never starts a model turn. Legacy unknown and rewound detached receipts show neither claim. Its preview is bounded to five logical lines, 4096 characters and five visible rows, so a single minified line cannot fill the chat; expand opens a keyboard-scrollable output area, with explicit UTF-8 byte paging and retention-limit notices. Command and loaded-output copy controls use the shared clipboard helper; partial captures say “Copy loaded” and show loaded versus retained bytes rather than implying the entire capture was copied. An admitted command reveals the latest output like an ordinary send; a failed admission leaves the composer draft and scroll position intact. Before the first request, the client synchronously persists the session, UUIDv7 and immutable command in browser localStorage; a retry, including after a reload, reads that same command id first and only resubmits it after an authoritative 404. The record is removed only after an authoritative result. Corrupt or unavailable storage refuses execution rather than risking a duplicate side effect. Referenced shell history content is hydrated when loading a transcript, so large receipts remain visible after a reload without eagerly fetching the full retained output. Stream/reset records update the entry by command id; live delivery acknowledgments overlay older canonical metadata without discarding its richer output preview. Late admission replies cannot undo completion or an acknowledgment, and reconnects do not duplicate entries. On reset, already loaded pending receipts refresh their delivery metadata with serial, abortable reads so an acknowledgment missed while disconnected cannot leave them falsely queued. Output is rendered as text, never interpreted as terminal escapes or markup. “Open command on canvas” keeps the receipt in the transcript and opens a tethered command window beside its chat (nodes/ShellCommandNode.svelte, views/ShellCommandView.svelte, shell-commands.svelte.ts). Its saved board record contains only the session/command ids and layout metadata, never output; opening it again focuses the same window. It uses the ordinary layout, focus and browser pop-out/back machinery. The viewer and browser popout show execution/output only, without a delivery indicator. The viewer reads the existing resource, follows its latest output every 700 ms while running, stops background polling once the finished output is caught up, and keeps at most 400,000 UTF-16 units; large backlogs jump to the last 64 KiB rather than replaying the entire capture. Read from start, previous/next UTF-8 pages, and follow latest let the reader visit the retained log without loading it all. Arbitrary tail offsets advance at most three bytes to find a codepoint boundary. Copy explicitly means the loaded portion when partial; byte ranges and capture-limit notices say what is missing. Hidden tabs pause requests and timers until visibility resumes. Closing a viewer aborts reads but never stops or executes the command; only Stop requests cancellation. Missing/deleted commands show an error. On a phone it opens in the chat dock, and in zen chat on the desk.

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 half of --open-tint, the session of the selected window on all of it (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. A click on an open session's row selects its window and shows it (its tab, the canvas panned to it, board.focus), and the window's frame pulses once. A row's time is the session's activity_at; a live activity event moves it only while a turn is in hand, since its observed_at also moves when nothing but a kernel starting for an opened window happened. [ (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. + or B makes a new board; double-click or F2 renames one; closing a tab (⇧W) 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 a layout tree (tiles/tree.ts): leaves name windows, and a split (side by side or stacked, with sizes), a tabs tile (one child shown at a time, any tile: a window, a split, a canvas) or a canvas arranges them. Any of the three can be the root (palette: "layout: canvas", "layout: tiles", "layout: tabs"; canvas to tiles cuts at the widest gap between windows, tiles to canvas keeps where each window was on screen, a tabs block as one; back to the canvas it left, while nothing moved in the tiles but windows opening or closing, the board restores that canvas as it was, view and all, and places the new windows on it; the canvas is saved with the board as left, so a reload between does not lose it), and they nest: a split can hold a canvas (palette: "new canvas beside this window"), a canvas can hold a split, and a canvas can hold a canvas (below), and a window is in a tree once. board.svelte.ts keeps the windows (board.windows, what each shows) apart from the tree (board.root, where each sits), and the selected pane in board.selected: a window's id, or a section's, group's or canvas's tile id. A new window lands in the container of the window it came from (or the selected pane): beside it on a canvas, or as a new tab, and on a selected canvas itself; in the tiles it splits the preselected tile, else the tile of the window it came from, else the biggest, along its longer side, as long as both halves keep LEAST, and with no such room anywhere it joins the selected window (or the biggest tile) as a tab. A gutter never squeezes a tile, nested ones included, under LEAST (leastAlong in tiles/tree.ts, counting a section's header and padding and a tabs tile's strip on top of the windows). The selected window, unless it is the only tile filling the board, has its title in the accent and a faint accent ring (tiles/WindowSlot.svelte), calmer than a pick's outline. A press on a section's, group's or canvas's header, or on a canvas tile's empty ground, selects that pane, marked the same way: its name in the accent and the ring on its tray or tile (TileView, nodes/SlotNode.svelte); a section that is the whole board is not one, and a section in a tab shows none, its tab carrying its name. Ungrouping or closing a selected pane lets the selection go. Every mark on a window (selected, picked, the pulse when its sidebar row is clicked, flash in board.svelte.ts) is drawn on its slot over the whole window, never on the frame, whose title bar would cover it. tiles/BoardView.svelte renders the tree (TileView, SplitView, TabsView): gutters drag to resize (arrows on a focused gutter, double-click evens them out), a window's title bar or tab carries it elsewhere (below), and ⌥ with an arrow focuses the window that way on screen (focus.ts), across splits, tabs and canvases, from the selected pane (a section or canvas by its box, never a window inside it): one overlapping it across the way wins, and one only diagonal to it counts when none overlaps. A note is never a tile: with no canvas to sit on it floats over the board (board.floats, in the board's own pixels, dragged by its header), beside what it is about, and a board with a note tiled by an earlier build floats it on load. Switching the root to a canvas puts the floating notes on it where they were; switching it to tiles or tabs floats the canvas's notes where they were. A window's title bar holds its chips, a ⋯ window menu, focus and close (nodes/WindowFrame.svelte). The menu has the window's zoom on top (its steps and level in one well), then the view's own entries (an artifact's reload) and pop out, and hangs from the bar so it stays inside the window; a bar too narrow for its chips moves them to the top of the menu, measured from how wide the chips stand unsqueezed. A session's title reads "title in project". With compact title bars (settings, prefs.compactBars) every bar is 24 px with the title, the menu, focus and close, and a session's chips sit under its composer instead, folding the same way, their menus opening upward. A window's body clips (overflow: clip) rather than scrolls, so a popover opened inside it is moved and cut to fit it (popover.ts). In a tile a torn-off chip window fills the tile; on a canvas it is as tall as its list. A note lying on the window it is about draws no tether. A split or a tabs tile left with one child becomes that child, unless it is a section.

Any tile may have a name. A named split or tabs is a section: in the tiles it is framed like a group on a canvas, a tray sunk below the board, each section inside another a step deeper (in the dark, a step lighter: near black, darker steps look alike) and edged with a 1px line (.tray in app.css), with a header as tall as a compact title bar (tiles/SectionHead.svelte, shared with nodes/SlotNode.svelte) holding its name (double-click renames, as an undo step; Enter or leaving the field keeps it, Esc keeps the old one, and so does an empty name, since ungroup is what drops it; an emptied tab name goes back to the window's own title; tiles/NameField.svelte), a handle that carries the whole section, and an ungroup button that drops the name and the frame and leaves the tiles where they are. The frame is a wrapper every split and tabs has, unseen (display: contents) until it is named, so naming one never rebuilds its windows' slots. A section stays while it holds anything, so a window moved within a section of two stays in it, and a named split never folds into a parent split of its axis. On a canvas a group's name is its tile's name (boards saved with it on the placed child are moved over on load, as are tabs that named their shown window rather than its tile). A tab is labelled by its tile's name, else its window's title, else what it holds (a split's windows, "canvas · 3 windows"; tileTitle in tiles/title.ts), and a double-click on it names it; for a window that names the tab only, never the window or its session. Inside a tab or as a canvas's child a section shows no frame of its own: the tab or the group header carries its name. Only a window's tab has a ×.

⌘-click (Ctrl-click off a Mac) on a window's title bar, tab or empty ground (anywhere off its text, fields and controls) picks it, and so does one on a section's, group's or canvas's header or a canvas's empty ground (board.picked, outlined or ringed in the accent; Esc or a plain click lets the picks go); picked tiles are gathered whole. "make section" (⌥G, or the palette) makes the picked windows one section and opens its name field, as one undo step: when they are all one split or tabs holds, that is named and nothing moves; in the tiles the others are gathered into a split where the first picked one was, across when that one was wider than tall; on a canvas they become a group laid out by where they sat (tileOut), at the top left of where they were, as big as the windows themselves. Windows on different canvases, or on a canvas and in the tiles, are refused with a line saying so. Svelte Flow's own selection stays off (elementsSelectable), so a canvas picks by ⌘-click too.

When the arrangement changes (a move, a swap, a drop, a layout switch; not a gutter or a move on a canvas), each window that moved slides from its old box to its new one at its new size, its top left corner leading and opening out to the right and down from its old size if it grew, so what shows goes from the old box to the new (tiles/reflow.ts, off under reduced motion). Svelte takes a slot out of the document before it lets its window go, so hosts.shelter parks the board's windows first and puts back those whose slot stayed: a window never leaves the document, and keeps its scroll. A chat keeps its place through any resize: at its end it stays there, and further up it stays as far through the block at its top as it was (Chat.svelte; plain scroll anchoring holds that block's top while the text rewraps).

Focus fills the board with one window (in the tiles, a tab, on a canvas or in a group; never a note) or one section or canvas tile in the tiles, or a group or canvas on a canvas (board.expanded): view state, never an undo step or saved with the board, so a reload shows the layout. Its button sits by a window's close (corners out, in again while focused, the icon the sidebar's full screen button uses too: tiles/ExpandIcon.svelte) and in a section's or group's header and a canvas tile's view bar; a double-click on a window's title bar (not its controls), ⌥F or "focus pane" in the palette toggle it for the selected pane, and Esc or the button goes back. Nothing behind it moves or remounts: a tile in the tiles is fixed over the board where it is (.expanded in TileView, sized from the board's box), and a window off a canvas is shown in the board view's own slot (WindowSlot over) while its canvas slot lets it go, sheltered on the way back; the rest stays laid out under visibility: hidden, so it takes no pointer, ⌥ with an arrow only reaches windows in what is focused, and slotOf leaves what is hidden behind it out, so tethers to it are not drawn. Floating notes stay over it. It slides like a layout change (reflow), keeps while the board is switched away and back, ends when what it shows closes or a step is undone, and a focused window is not carried (drag does nothing until it goes back).

Move to a new board (palette) takes the picked panes, else the selected one, off the board with everything in them and makes them a new board's whole layout, the board named after the pane when there is one (Board.give, Board.receive, boards.popToBoard). It is not an undo step, shells in them live on, and canvases keep their views; the board as a whole cannot be moved.

Pop out puts a pane (a window but a note, a section, a group or a canvas; never the whole board) in a browser window of its own (popouts.svelte.ts, Popout.svelte): a window's menu, or "pop out pane" in the palette for the selected one (both a click or a key, since the browser opens popups only for one; none on a phone). The popup is a page of its own, /?pop=<board>&window=<the window's record>, or for a section or canvas &pane=<its tile's id>&tile=<its tile and windows> (Board.pane; past 4000 characters the record goes in sessionStorage, which a popup starts with a copy of, and the address carries @<key>), which main.ts mounts instead of the app: its own realm and socket, so it keeps streaming while the board's tab is hidden. A window shows in its normal frame, whose close button closes the popup; a section or canvas is the whole of the popup's board (BoardView, without the name its frame showed), live there (drag, docking, tabs, splits, a canvas's pan), and any window's close button closes the popup. Its board (boards.board, made from the record by Board.showPane) is never saved (popped in board.svelte.ts), and what it opens (a review, a terminal, an artifact the agent made) opens on the board it came from (openElsewhere). The board takes the pane off as a close would, reflowing, but not as an undo step and without ending its shells (Board.popOut), and keeps it in away with where it sat, saved with the board. The two talk over a BroadcastChannel: when the popup goes (pagehide, or the board sees its handle closed) the pane comes back at once if the board opened it and its handle says closed, else once 1.5 s pass without the popup saying it is back (a reload), where it sat: off a canvas into the layout it left while nothing else has moved since (as ⌘Z brings back a close), else at its spot when that is still there and has room (its place on a canvas; beside its old neighbour, or among its tabs, in the tiles), else as new, in the shape the popup last had, its canvases at their views there and a section with its name again (Board.bringBack); a window opened on the board again meanwhile stays as it is and is left out of it, since a chat may be on the board and popped out at once. After a reload the board asks who is out, and a pane away that no popup answers for comes back. The popup follows the board's prefs through the storage event.

A tiled window (or a tab) carried by its title bar or tab is lifted out of the layout once the pointer leaves the place it was carried from (board.lift; until then nothing moves, that place is hatched and the line says "stays put"): its place closes, its window stays mounted (hosts.hold), and every target is aimed at the layout without it (dock.svelte.ts). Over a tiled window, the outer quarter of each side splits beside it, the middle joins it as a tab, and its title bar or tab strip joins it as a tab at the caret (a tabs block showing a split or a canvas is aimed through: the windows in it are targets, its strip is the tabs'); over a section's header it joins the section as a tab; where a side is also an ancestor's edge (a section's frame, its header and padding, still counts as the same edge), the last 12 px per level drop beside that ancestor instead (the outermost at the very edge, levels that would make the same layout left out), and with Shift held the window under the pointer trades places with it (outlined where the carried one lands, its place before the lift). A target holds until the pointer is 8 px past where it was chosen, and the gap between two tiles aims at the nearer. Each aim is worked out on the tree before it is drawn: a tinted half (or the whole window and a caret for a tab, ⇄ for a swap), sliding between targets, with a line in words below and right of the pointer, flipped near the window's edges ("left of diff", "below the whole board", "tab after diff", "swap with diff", "onto the canvas"); one that would squeeze a tile under LEAST (160 px, the floor a gutter keeps too) shows red naming it ("no room: diff would get narrower than 160px") and does not drop. A tile already under it only counts if it shrinks further, and joining tabs is never refused for room. Wherever letting go would put the window back (a drop that changes nothing, Esc, a right-click, letting go over nothing) its old place is outlined instead and the line says "stays put" (carry.home). With ⌥ held a tiled window is carried from anywhere in it, as on a canvas. Over a canvas, a canvas window docks only on its guides (with the same line in words), and a window let go over a canvas lands there, held by its title bar and outlined at its own size (#landing); a tiled window carried near the edge of a canvas tile (its outer quarter, and the strips) splits beside it as beside any tile (dock.svelte.ts #onCanvas; the root canvas has no such edge). A group of windows on a canvas is a little tiled layout: a window in it is lifted out when carried, and over a group a carried window (one alone on the canvas too, looked through as it rides under the pointer: #under) aims at the group's tiles as at the board's (#groupAt), its outermost strips beside the whole group and its header joining it as a tab; off the group it is the canvas again. On a canvas only what is in view is aimed at and outlined (#box clips to the canvas tile), so a drop preview never spills over the tiles beside it. A canvas inside the layout is drawn as a tile: rounded, edged and a step off the ground (--rail), so two side by side read as two (TileView.svelte). It has a grip in its view bar that carries the whole canvas the way a window is carried: lifted out of the layout (board.lift takes a tile as well as a window, holding every window in it), beside another tile by its sides and strips, or into tabs by a centre, a title bar or a tab strip (board.moveTile); its windows and view survive the move, its Svelte Flow is made again. A tab strip never squeezes a tab under 112 px: it scrolls sideways (the wheel too) and keeps the shown tab in view. A shell's title is one function of its record and the cwd its shell reported (shells in tiles/title.ts), so the window and its tab read the same. A section's header and a tab that holds more than a window carry their tile the same way, so a section dragged onto a window's title bar becomes a tab beside it. A canvas in a hidden tab stays mounted and inert, as a hidden window does.

Keys arrange the layout as well as drag does (App.svelte, tiles/moves.ts, tiles/Marks.svelte). ⌥⇧ with an arrow moves the selected pane (a window, or a section or canvas tile, carried whole by board.moveTile): past its neighbour in a split, into a tabs block or a split lying that way (beside the child level with it), out one level at the edge of its split or tabs, and beside the whole board at its edge; in a group on a canvas it moves within the group, and a window, group or canvas placed on a canvas is not moved (drag it). ⌥⌃ with an arrow swaps the selected window (only a window) with the window wholly that way on screen (nearest in focus.ts), notes excepted. ⌥/ puts a letter on every window on screen and on hidden tabs: a letter swaps that window with the selected one, ⇧ with a letter joins the selected one to it as a tab. ⌥P then an arrow preselects that side of the selected window (board.presel, drawn as a dashed, tinted half): the next window opened, or the next one picked with ⌥/, lands there. Esc leaves the letters, then lets the picked windows go, then clears the preselection, then ends a focus. A move or swap that would squeeze a tile under LEAST is refused with "no room" before anything moves, by the check on the tree a drag uses (board.squeeze, squeezed in tiles/tree.ts). ⌘Z / Ctrl+Z undoes a layout change and ⇧⌘Z / Ctrl+Y redoes it (board.undo, redo: moves, swaps, resizes, root switches, groups, names, and closing a window or a note with text, which says so where the window was ("closed ⌘Z undo", a pill in <code>tiles/BoardView.svelte</code> with an undo button, 3.5 s then a fade, held while pointed at, gone once the board changes again) and comes back where it was as the same window, a shell as a new one; opening windows is not a step, so one opened since is placed again). None of these act while a text field or a terminal has focus. Where a window sits (alone on a canvas, floating, a tab, in a group, a tile) is <code>board.placeOf</code>, stamped on its slot as <code>data-place</code> for styles that differ by it.</p> <p><strong>Stacking</strong> has one scale, the <code>--z-*</code> layers at the top of <code>app.css</code>; a z-index that orders one component against another reads from it, and any other z-index only orders the parts inside its own component. Every window slot is a stacking context (<code>WindowSlot</code>), so nothing inside a window (its header, composer, menus) can climb over a floating note, and the board view isolates, so the board's layers (windows, tethers, floats, a window whose menu is open, the selection's note button) never meet the app's (peek, zen, panes, docking, palette). A window is lifted while a popover it opens is showing, found by <code>[aria-haspopup][aria-expanded=true]</code>, so give a new popover button both. Canvas children and floats keep their stacking order as 1..n (<code>raised</code> in <code>tiles/tree.ts</code>), so selecting never grows the saved numbers.</p> <p><strong>Windows mount once</strong> (<code>tiles/hosts.ts</code>): each is <code>mount()</code>ed into a host element, in a microtask so it never belongs to the effect of the slot that first showed it (svelte parents a new root under the active effect), and moved into whichever slot shows it (<code>tiles/WindowSlot.svelte</code>) with <code>moveBefore</code>, which keeps an artifact's page, a shell, scroll and focus; where the browser lacks it (Safari) the move is a plain one, which reloads an iframe but remounts nothing. A slot that goes away parks its window out of sight, and a window still parked on the next frame is unmounted, so a move between tiles never remounts and a closed window or a hidden board holds nothing. A tabs tile keeps every tab's window mounted, the hidden ones <code>inert</code> and <code>content-visibility: hidden</code>. Hosts are keyed by board and window, so the same chat on two boards is two mounts.</p> <p>A canvas lets windows sit anywhere; the view pans freely and zooms out to an overview. Svelte Flow draws, drags and resizes what is placed on it (one generic <code>slot</code> node, <code>nodes/SlotNode.svelte</code>, holding a window or a split); <code>board.svelte.ts</code> places new ones (right of the selected window, top-aligned, stepping past overlaps) and keeps each canvas's viewport. Every canvas, the root or one inside the layout, has its own view bar (<code>tiles/CanvasControls.svelte</code>): key hints, zoom, fit and its overview map (<code>map</code> on the canvas tile; the old app-wide <code>albedo.minimap</code> seeds a canvas root once), showing while the view moves (and, for a canvas inside the layout, while the pointer is over it); the hints, on the board's own canvas only, keep one row and drop the ones that do not fit whole, and a narrow canvas tile drops fit and the map, then the zoom steps, keeping the zoom level (still a reset to 100%), the grip and close. A canvas inside the layout also has an ungroup button, which tiles its windows in its place by where they sat (<code>tileOut</code>) and floats its notes (<code>ungroupCanvas</code>), and a close button, which closes it with every window on it as one step ("closed <name> · 3 windows"). A note let go of outside its canvas goes onto the canvas it was let go over, else floats where it is, and a floating note let go of over a canvas joins it there. Windows docked together on a canvas sit in a group (<code>nodes/SlotNode.svelte</code>): a frame a step off the canvas in tone, with a header that drags the group, its name (<code>name</code> on its tile; double-click to rename), a focus button and an ungroup button that lets it go one level: each part (a window, a tabs block, a split) stays where it is on screen, keeping its own shape. Docking beside a window or group on a canvas grows it by the window dropped rather than halving it, and a window inside a group is carried, not dragged with the group, so it can be dropped on the canvas to leave. A chip window is as tall as its list only alone on a canvas; in a tile or a group it fills its place. The wheel is handled in <code>Canvas.svelte</code> 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 its text is readable (at least 8 physical pixels tall: the canvas text size × the canvas zoom × the window's own zoom × <code>devicePixelRatio</code>, which counts a Retina screen and the browser's zoom, so a 1× screen keeps windows scrolling down to about 60% and a Retina one to about 30%; scrolled natively; when the browser's scroll stays latched on the canvas, after a pinch over empty canvas or a pan under a still pointer, and a frame passes with room to scroll and no movement, the rest of that gesture moves the window by the fingers' own deltas, with no animation), 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 <code>composedPath()</code>), a menu floating over the canvas (<code>MenuButton</code>'s list) or a view that pans and zooms itself (the agents tree), marked <code>holds-wheel</code>, keeps every scroll over it at that zoom even when it has nothing to scroll, 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% (<code>Board.centreOn</code>, 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. On a touch screen (<code>Canvas.svelte</code>), two fingers anywhere, over windows too, zoom the view around the spot between them and pan it with their travel (Svelte Flow's own pinch stays off, and Safari's page pinch is cancelled anywhere while the canvas is up; <code>index.html</code>'s viewport sets <code>maximum-scale=1</code>, because iOS zooms the page into any focused field under 16 px and never zooms back, which left the canvas and sidebar off screen); one finger scrolls a window natively or pans empty canvas, and the touches of a pinch go no further until every finger lifts. Under <code>pointer: coarse</code> the invisible resize edges reach 14 px and the corners 40 px past a window, and edges, corners and title bars take <code>touch-action: none</code> so the browser never claims their drag as a scroll. + and − zoom the canvas 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, W closes the selected pane (a section, group or canvas with every window in it, as one step, <code>closeTile</code>), Enter focuses its field (and Escape leaves a chat composer so canvas keys work), S opens the new-session dialog, A opens the artifact gallery, R refreshes the session list, and ? opens the about pane. Each window also keeps its own zoom (<code>data.zoom</code> on the board, 50% to 200%, applied to <code>WindowFrame</code>'s <code>.body</code>): its menu has <code>− 100% +</code> (the level resets it), and while the window is zoomed its level shows in the title bar in the accent and takes it back to 100% when clicked, Alt (Option) with a scroll over a window zooms that window, and ⌥+, ⌥− and ⌥0 step or reset the selected window's zoom. 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 <code>Gesture.svelte</code>, 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 <strong>view</strong>, each framed by <code>nodes/WindowFrame.svelte</code>: 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 <code>parent</code>, opens beside it, and the canvas draws a faint dashed line between them (the board derives Svelte Flow edges from those parents; <code>tetherSides</code> chooses facing left/right or top/bottom handles from where the two sit on their canvas and how big they are, so moving a child past its parent does not fold the curve across either window; every slot carries invisible source and target handles on all four sides, <code>nodes/SlotNode.svelte</code>).</p> <p>A canvas can sit on a canvas, to any depth (palette: "new canvas on this canvas", or "new canvas beside this window" with a window on a canvas selected). It is a node like a group, framed as a tray a step deeper, its header naming it (double-click to rename), focusing it, ungrouping it and closing it; ungrouping lets what is on it go onto the canvas round it, each where it shows and keeping its size, and closing (<code>closeTile</code>) closes it with every window on it, each one undo step. It pans and zooms on its own: its own view (<code>board.views</code>, saved with the board) is drawn inside the outer one, so its windows show at both zooms, and its view bar shows its own: without the key hints (the board's own bar says them, as it does for every canvas inside the layout), only while the pointer is over it, and stepping down with its size on screen as a narrow canvas tile's does, low as well as narrow (the container query measures both). Its pane is drawn as many times bigger as the outer canvas shows it and scaled back down (<code>board.scaleOf</code>, <code>board.shownOf</code>), so its Svelte Flow works in screen pixels and a drag, pan or drop lands under the pointer; as the outer view moves the board tells each inner flow the view it now draws. The pointer decides: the wheel, a pinch, space and a drag, a drag on empty ground, ⌥ and a drag, and the canvas keys act on the innermost canvas under it (<code>board.pointed</code>, else the selected window's), and the canvases round it leave them alone: each takes only what happens where the nearest <code>.canvas</code> is its own, an inner flow has its own <code>noPanClass</code> (the node it sits in is <code>nopan</code> to the outer flow) and its own <code>SvelteFlowProvider</code> (a flow inside another finds the outer one's and throws as it goes), and a window carried off it does not pan it. A window carried onto it lands inside where it is let go, at its zoom, and back out the same way, the landing outlined, and is never mounted again on the way. Selecting a window raises it and every canvas round it. Focus fills the board with it at its own zoom, drawn in the board view's own slot as a window off a canvas is, its node empty meanwhile. Fit is the board's (<code>getViewportForBounds</code> and a glide) rather than Svelte Flow's <code>fitView</code>, which stays queued until its glide ends and, when one is cut short, fits again on every change after.</p> <p>Window connectors stay behind windows while at rest, even when a linked window is selected. <code>Canvas.svelte</code> enables <code>elevateEdgesOnSelect</code> only during a window drag (<code>node.dragging</code> or the canvas’s Alt-drag <code>carrying</code> state), so its links show over other windows while positioning it and return behind them on release. Selecting a window still raises that window without bringing its resting links over unrelated contents.</p> <p>Board-level SVG links follow the same rule: list-to-window links (agents, jobs, artifacts), note curves and the hovered trace connection are clipped outside visible window interiors while at rest (<code>ConnectorClip.svelte</code>, <code>connectorOcclusion.ts</code>). Window dragging temporarily lifts them. The clip also excludes invisible note-line hit areas, including where windows overlap; target highlights and short note endpoint stubs remain visible, and windows panned outside a nested canvas do not occlude neighbouring tiles.</p> <p>The review (<code>nodes/ReviewNode.svelte</code>, <code>review.ts</code>) 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 (<code>data.jump</code>). It needs no daemon support beyond history: every tool call's trace records each file it changed, with the unified diff (<code>changes[].diff</code>, or <code>reason</code> when the file was binary, unreadable or too large). Writes the harness makes for itself stay out of it (<code>albedo_trace.unobserved()</code>): the browser plugin's profile lock and marker and its auto-named screenshots (in the cache, see <a href="kernel.md#browser-gestures">kernel</a>) 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 (<code>after=</code>, 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 <code>@pierre/diffs</code> (Shiki highlighting, <code>pierre-dark</code>/<code>pierre-light</code> tokens over the canvas's own ground and diff colours; <code>views/DiffView.svelte</code>), 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 (<code>readTurn</code> without <code>seek</code> 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, a file whose changes grew glows in the index for a moment, and the review follows its end as new changes arrive while the reader is at the bottom.</p> <p>The <strong>terminal</strong> (<code>nodes/TerminalNode.svelte</code>, <code>views/TerminalView.svelte</code>, <code>jobs.svelte.ts</code>) shows one background job's output, read-only. An activity pulse in a chat's title bar (<code>JobsButton.svelte</code>; not a prompt, which is kept for a live terminal: the chat's own <code>>_</code> button beside its folder chip opens one in the session's folder, and the folder chip only names the folder; on the phone above the composer, shown once the session has had a job; it and the artifacts icon share <code>MenuButton.svelte</code>) counts the running jobs and lists them, finished ones with their exit codes while the kernel keeps their output; the list polls <code>GET /extensions/run/sessions/{id}/jobs/recent</code> 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 <code>.../jobs/{job}/output</code> and follows <code>next_offset</code> every 700 ms while the job runs (once more after it ends), keeps at most 400,000 characters, drops colour codes and shows a <code>\r</code>-redrawn line as its last draw. Its stop button asks twice, then posts <code>.../jobs/{job}/stop</code>. 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.</p> <p>The <strong>interactive terminal</strong> (<code>nodes/PtyNode.svelte</code>, <code>views/PtyView.svelte</code>, <code>terminals.svelte.ts</code>) opens a live pseudo-terminal running the user's shell in any workspace. T or "new terminal" in the command palette opens one beside the active window, and clicking a chat's workspace path pill opens one in that session's folder. Underneath, <code>@xterm/xterm</code> (v6) with <code>@xterm/addon-fit</code> and <code>@xterm/addon-web-links</code> provides terminal emulation, clickable link opening, and dynamic theme synchronization with the canvas's madoromi colors and mono font preferences. The window connects over a WebSocket at <code>/api/terminals/{id}</code> to the daemon's PTY service (<code>albedo_terminals.erl</code>, <code>albedo_pty.py</code>), negotiating row/column sizing on resize and retaining output history across reconnects and board switches. Every attachment sends its <code>ready</code> frame before history or live output. An in-stream restart keeps its requesting socket attached, closes any other watchers, then sends that socket the new <code>ready</code> frame before output from the replacement PTY. Output is flow controlled: the worker puts <code>"credit": 8</code> in the spec it sends <code>albedo_pty.py</code>, and only then does the helper wait for credit (inbound frame <code>0x05</code> + uint16), so a running daemon's older worker that starts a newer helper from disk still gets unthrottled output, and a newer worker's credit frames are ignored by an older helper (<code>test/harness/pty_protocol_test.py</code> checks the first). With credit, the helper sends at most 8 output frames of at most 16 KiB ahead of the worker, so at most 128 KiB of output is read ahead; the worker grants one frame per frame it handles, and with no credit left the bridge stops reading the PTY, so a fast producer blocks on the PTY instead of filling the daemon. The worker serves attach, detach, input, resize, signal, restart and stop before queued output. A socket whose mailbox holds more than 32 messages is behind: its output is held (at most 64 KiB, newest kept) and retried every 20 ms, then sent as one binary frame once it catches up, and before any exit event. When bytes were dropped that frame starts with <code>\r\n\e[0m[albedo: skipped N bytes of output while this view was behind]\r\n</code>; the other sockets and the reconnect history are unaffected. The shell gets every key, Esc and Tab included; ⌥Esc (<code>attachCustomKeyEventHandler</code>) is the one way out to the board's keys, leaving the focus on the page with the window still selected. The footer displays live dimensions (<code>cols × rows</code>), that key (<code>⌥esc leave</code>), signal interruption (^C), clear, and restart controls.</p> <p>The <strong>trace</strong> (<code>nodes/TraceNode.svelte</code>, <code>views/TraceView.svelte</code>, <code>trace.ts</code>, <code>TraceTie.svelte</code>, <code>tracelink.svelte.ts</code>) 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 (<code>turnBefore</code> walks history back to the person's previous message; the count is saved on the window as <code>earlier</code>), read in place so the view keeps its zoom.</p> <p>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 <code>since_ms</code> 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.</p> <p>Rows come from three places: model calls from the session's request records (<code>GET /sessions/{id}/context?view=requests</code>, oldest first; <code>since_ms</code> 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 <code>history/content</code> walk per agent), and agents from <code>GET /sessions?parent_id=</code>, 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 (<code>GET /extensions/run/sessions/{id}/jobs/recent</code>, 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 (<code>callAt</code>): 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 (<code>spent</code>): 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.</p> <p>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 <code>onreview</code>), 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 (<code>traceLink.entry</code>, keyed by the chat's entry: the result once there is one), and <code>TraceTie</code> 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 <code>traceLink.reveal</code>, 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 (<code>clockOf</code>, a polylinear d3 scale); ticks are round elapsed times within each busy stretch. Zooming, panning and replaying only move CSS variables (<code>--w</code> the axis width, <code>--left</code> the pan, <code>--t</code> 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 <code>.zooms-itself</code> 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. <code>prefers-reduced-motion</code> drops every animation but the replay.</p> <p><strong>Agents</strong> (<code>AgentsButton.svelte</code>, <code>agents.ts</code>, <code>nodes/AgentsNode.svelte</code>, <code>views/AgentsView.svelte</code>): once a session has agents below it, its window shows a branching-icon button beside the jobs one (in canvas windows only; the phone has none). Its count is the agents working, and its menu lists them (oldest first, closed ones too, indented by how far below the window's own session they are) under an "open tree" row. A family is read whole from its root with <code>GET /sessions?family_id=</code> (<code>listFamily</code>), once for every window of it, however many show it (<code>watchFamily</code> shares the read and the newest answer). It is polled, since the events socket follows only roots, and <code>familyDelay</code> sets the pace: every 2 s while an agent works, 3 s while the root has a turn, 6 s at rest with agents and 30 s with none, at once when the root's turn starts or ends (<code>pokeFamily</code>) or the tab comes back, and never while the tab is hidden. A read that changes nothing tells no one, and a first read that fails says so. An agent picked opens as an ordinary chat window beside the one it came from, tied to it by a tether (<code>Board.openSession(id, parent)</code>). "open tree" opens the agents window for the family's root (<code>layoutTree</code>): active branches grow to the right of the root while closed folds and finished branches balance to its left when active work is also present; large leaf sibling sets (> 5) stagger across sub-columns with a half-row vertical offset so links glide between rows, and two or more closed leaf agents under a parent fold into a <code>▸ N closed agents</code> deck node that unfolds its compact closed pills in place on click without shrinking the fitted zoom (<code>fitBox</code> and <code>shapeOf</code> ignore unfolded closed pills, and clicking a fold pins it under the pointer while contour tucking keeps sibling branches from overlapping). Active cards show their name, short model or live face (on roots and leads), and latest tool/activity snippet; closed pills use an opaque dimmed surface, and links sit at 22% opacity at rest and light the hovered card's ancestor chain in <code>--accent</code> (90%) while stepping down monotonically across descendant hops (<code>hoverLineage</code>: 62%, 42%, 30%, vs 14% for unrelated branches). A click opens an agent, and a "hide inactive" button (shown once any agent is not working) leaves out the agents that are not working, idle and closed alike, unless one is working below them (<code>withoutInactive</code>), the window shrinking to what is left; the choice is kept in this browser. The tree pans and zooms itself: a left-button drag on its ground cancels native text selection, and only the tree is non-selectable, so panning cannot extend a selection into neighbouring conversation blocks while their text and the tree's buttons retain their usual interactions. It starts fitted and centred with room around it (48 px, enlarged up to 135% when the window is bigger than it needs) and follows the window while it is resized, until the person drags the ground, scrolls, pinches or uses its + and − buttons, after which it stays where they put it until the fit button follows the window again; an "auto-fit" button (on by default, kept in this browser) also fits it whenever the tree's shape changes (<code>shapeOf</code>: an agent added, removed or hidden, not one only doing something else), and the window itself is sized to the tree either way until it is resized. The window is sized to hold the tree at 115% and its room (<code>AgentsNode</code>, at most 1500 by 960), and again when agents come or go, growing only into free room (<code>growInto</code>: it stops 32 px short of a window beside or below it, so it never covers the chats opened from it), until the person resizes it. The size it was last fitted to is kept on the window (<code>data.fit</code>, saved with the board), and it is fitted again only while it still has that size (<code>refit</code>), so a size the person set holds through a reload. The session list holds only roots (<code>scope=roots</code>), so a window of an agent keeps its record in <code>sessions.agents</code> (<code>adopt</code> when it opens, refreshed by each family read, and <code>ensure</code> reads it back for a window the board restores, counting only a 404 as gone).</p> <p>The <strong>artifact</strong> (<code>nodes/ArtifactNode.svelte</code>, <code>views/ArtifactView.svelte</code>, <code>artifacts.svelte.ts</code>; robot-docs/artifacts.md) is a page the agent made, live in a sandboxed <code><iframe></code> (no <code>allow-same-origin</code>) on <code>/frame/{id}.{key}/{version}/{entry}</code>. The chat learns of one from the <code>artifact</code> activity in a cell's trace: a line under the turn's summary (<code>made counter</code>, <code>updated counter v2</code>) opens it, and one that arrives while the chat is open (<code>Transcript.made</code>, 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 (<code>Board.revealTogether</code>, 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. The tab button paired with its version chip opens <code>/frame/{id}.{key}/latest/</code> 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 (<code>PageHost</code>): the theme and the client's colours resolved to plain values (<code>themeVariables</code>, sent again when the theme or palette changes), the page's messages to the chat headed <code>From the artifact “<title>” (v<n>):</code>, 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 resized, or below 60% zoom, the frame lets the pointer through. <code>SlotNode</code> sets <code>board.resizing</code> between <code>NodeResizer</code>'s start and end, which marks every canvas <code>.resizing</code>, and meanwhile every artifact frame ignores pointer input so crossing into a sandbox cannot swallow the resize's movement or release. On the canvas the page is zoomed, not scaled (<code>ArtifactView</code>): the frame gets CSS <code>zoom</code> at the canvas's zoom and <code>scale(1/zoom)</code>, 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 (<code>DRIFT</code>), and takes it exactly once the canvas holds still for 40 ms (<code>SETTLE</code>); the page is scaled the rest of the way in between. The page sees a <code>devicePixelRatio</code> of the screen's times the zoom; <code>PageHost</code> maps points through the frame's on-screen size against its <code>clientWidth</code>, 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 <code>open an artifact…</code> and <code>delete an artifact…</code> list every one, from deleted sessions too. <code>npm run dev</code> proxies <code>/frame/</code> like <code>albedo web</code>, with the same headers.</p> <p>A loaded skill (robot-docs/skills.md, loaded skills) shows as a muted <code>skill · <name></code> line: under the summary of the work stretch whose cell loaded it (<code>loadedIn</code> in <code>transcript.svelte.ts</code>, drawn by <code>WorkSteps.svelte</code>; a cell that did nothing else reads as its python cell in the summary), and in the foot of the user's message for a slash activation (the entry's <code>skill</code> JSON part). <code>SkillsButton.svelte</code>, a book icon with a count in the title bar, lists the session's loads from <code>GET /extensions/skills/loads</code> with each one's scope and when it was last loaded, read again when the session's work stops and when the list opens; it shows nothing until the session has loaded one.</p> <p>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.</p> <p>The client shares the terminal's voice. <code>moods.ts</code> is a port of <code>cli/internal/tui/moods.go</code> (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 (<code>preparing</code>, <code>generating</code>, <code>running</code> a tool, <code>compacting</code>, <code>interrupting</code>; only <code>idle</code> is at rest) and the tail of its activity, on one shared 500 ms clock for every face on the page (<code>live.svelte.ts</code>). Every frame of the animation sits invisibly under the current one, so a face keeps its width and nothing beside it moves. <code>burst.ts</code> writes a turn's thinking and tool calls as the terminal's one past-tense line (<code>thought 12s · read tui/{chat,moods}.go · ran go test ×2 +12 −3</code>), 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 (<code>Dots.svelte</code>): 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.</p> <p>Markdown (<code>markdown.ts</code>) also understands maths and diagrams: <code>$…$</code>, <code>\(…\)</code> and <code>$$…$$</code> / <code>\[…\]</code> (a dollar amount like "$5 or $10" stays prose: the opening <code>$</code> must touch its text and no digit may follow the closing one) and <code>```mermaid</code> blocks. They render as placeholders carrying their source, and <code>enhance.ts</code> draws them after the message mounts: KaTeX for maths (<code>trust: false</code>), Pele (pele.run) for diagrams, themed with <code>--pele-*</code> 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.</p> <p><strong>Notes</strong> (<code>nodes/NoteNode.svelte</code>, <code>anchors.ts</code>, <code>notes.ts</code>, <code>quote.ts</code>, <code>Tethers.svelte</code>) are small text boxes on the canvas, saved with the board. Double-click empty canvas or press N for a loose one (every new note goes to the free spot nearest where it was asked for, clear of the notes there and floating over it); 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 (<code>data-entry</code>), 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 <code>data-diff</code> 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: <code>pageHosts</code> in <code>artifacts.svelte.ts</code> 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 <code>robot-docs/artifacts.md</code>); dropping it on empty canvas sets it loose. A note panned out of its canvas's view draws its line dashed from the canvas's edge: the edge facing the passage, level with it, when the passage is outside that canvas (so the line never crosses the canvas), else the edge nearest the note; a click on that line goes to the note (<code>Tethers.svelte</code>). Out-of-view notes whose lines would lie on one another draw one line with their count at the canvas edge, and a click shows them all (<code>board.revealTogether</code>) and scrolls the chat to the nearest of their passages when that is out of sight. A line fades the further its passage is scrolled out of sight, as a share of all its chat has to scroll (<code>anchorPoint</code>'s <code>far</code>), and lines that end on one spot (their passages scrolled away past the same edge) show how many there; a click on that count scrolls the chat to the nearest of them (<code>anchors.ts</code> <code>showTarget</code>). A passage of text, the client's own or a page's, is kept as the W3C TextQuoteSelector keeps one (<code>quote.ts</code>): 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. A board switch remounts the board's windows, so each chat keeps where it was read (the keyed block at the top of its thread and how far through it, by window) and comes back there after its first page, reading earlier pages until that block is in (switched away before it got there, it keeps the place it had); a chat at its end comes back to its end and follows it. Tables in messages scroll sideways instead of squeezing their columns. A review names each file by its last two path segments (<code>…/chat/Chat.svelte</code>), the full path as its title.</p> <p>While the grip is dragged, what it would land on is outlined (<code>pick</code> in <code>anchors.ts</code>); 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 (<code>albedo-note-normal</code>, <code>-lit</code>, <code>-sent</code>), 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, frame by frame from whatever could have moved it (input, a scroll, a resize, the board's DOM changing, a message from a page) until a few frames in a row draw nothing new (the review kit audits that: with <code>window.__albedoAudit</code> set, every 500 ms at rest a frame is computed without drawing and compared, and a difference lands in <code>window.__albedoStale</code>; <code>test/manual/ux/tethers_check.py</code> drives the flows that could cause one), held inside the window and whatever scrolls it; it comes to the window's edge level with its passage and goes a short stub in, so it never crosses the window's text, and it passes under notes (each note's box is masked out of the lines, and clipped out of the ones a click follows). A click on a tied note's title (a press that did not move, so dragging the note by it still moves it) shows what it is about: its window brought into view, beside the note when they share a canvas (<code>board.revealTogether</code>), and scrolled to it (<code>showTarget</code>). 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.</p> <p>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 (<code>Board.sessionOf</code>), goes with that chat's next message: the chat's own notes first, then each window's in board order, each from the top (<code>drafts</code> in <code>notes.ts</code>). 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, <code>with next message</code> or <code>on its own</code>, and a click on it switches (<code>alone</code>, saved with the board; boards saved with <code>held</code> read as <code>alone</code>): 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. Your bubble shows a sent note's quoted lines as a quote and its fenced code as code; the rest of a message stays as typed, not markdown. 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.</p> <p>On a narrow screen (760 px or less) the client is a plain chat app unless settings ask for the canvas (<code>canvasOnPhone</code>): <code>layout.svelte.ts</code> and <code>Phone.svelte</code> show the session list, then one session's chat filling the screen, with a back button and the browser's back between them (<code>history.pushState</code>). There a review opens inside the chat, under the line that asked for it (<code>views/ReviewView.svelte</code>, the review window's body too), and notes and pop-out are off. The phone's session list and drawer are one picker (<code>SessionPicker.svelte</code>): 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; the chat you are in, tile or row, takes a calm tint of the accent over its tone), 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 slim bar names the session and leads back to the list (the browser's back does too), with the model and the artifacts, skills and jobs buttons on its right, the chat takes no gesture from the browser, and a handle on top of the composer raises the session picker as a drawer over the chat. Its composer stands on a band of the chat's ground, so the thread fades out behind the handle and composer rather than showing between them. 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).</p> <p><strong>Pin and archive</strong> (<code>Rail.svelte</code>, <code>Sessions.mark</code>): a row in the sidebar shows a full-screen, a pin and an archive button while pointed at or focused. Full screen (<code>FocusChat.svelte</code>, <code>layout.focus</code>) is zen mode: the session as one calm chat over the whole window, and its window opens on the board too, so it is there after. The chat is <code>inline</code> and <code>full</code> (<code>Chat.svelte</code>): nothing floats over the thread, and the model, artifacts, background jobs and a terminal button sit in a row under the composer's field (<code>Composer</code>'s <code>tools</code>). The header and those chips rest dim until pointed at or focused. What the work makes lands on a desk beside the chat instead of in the stream, right of it by default (settings: "zen desk on the left", <code>prefs.deskLeft</code>) (<code>onopen</code>, <code>chat/panel.ts</code>): an artifact as it is made or picked (<code>ArtifactView</code> with <code>fill</code>, at 1:1 off the canvas), a turn's changes when it is reviewed or when a turn that changed files ends, a job's output, and the shell, a <code>PtyView</code> in the workspace. The desk keeps up to six tabs for the sitting, never dropping the shell; the gap beside it resizes it (drag, ← → when focused, double-click resets; <code>albedo.focus.desk</code>). Escape only hides, an open menu or a field first, then the desk (the shell and page live on under it), then the chat; ⌘\ shows or hides the desk; a tab's × closes it, and closing the shell's tab or the chat ends the shell. Each is <code>PATCH /sessions/{id}?view=configuration</code> with <code>preferences: {pinned}</code> or <code>{archived}</code> (<code>configureSession</code>, read at its ETag first), updated in place on that session alone without a full list refresh (ignoring the daemon's self-triggered collection invalidation for the just-saved session), and put back with the daemon's reason if refused. Archived sessions leave the sections and sit under a collapsed "archived" fold (open while filtering), dimmed, with an unarchive button. Pinned rows sort by <code>pin_order</code> and move among themselves, dragged (a line marks where one lands) or with Alt and ↑ ↓ on the focused row: <code>Sessions.movePin</code> patches <code>preferences: {pin_before: <pinned session id> | null}</code> (null is last), shown at once and put back if refused, and the daemon renumbers every pin from 0 in the same commit, bumping each moved session's revision so a stale ETag on it fails with 412. Today and this week are ordered by activity and cannot be moved. The phone's picker offers neither pinning nor moving yet.</p> <p>search (<code>format.ts</code>) shares the tui’s matching rules and <code>test/fixtures/search/cases.json</code>: exact/prefix matches, identifier initials, substrings, then word-local abbreviations, with stable ties and each query word matching some field. unicode is matched scalar-wise with simple lowercase, accents and combining marks retained; explicitly typed punctuation stays literal. hit offsets refer to the original utf-16 text and never split astral characters; the fixtures use scalar positions. see <a href="tui.md">tui</a> for the cross-client contract.</p> <p><strong>New session</strong> (<code>NewSession.svelte</code>, the folder rules in <code>folderQuery.ts</code>; S, the + beside the sidebar's filter, the empty board, the phone list and ⌘K) asks three questions, one card at a time, in a fixed-size dialog (the whole screen on a phone): where to, which model, what should we do. Each card is its question as a heading over one field with the composer's focus ring, and rows on the surface under it that fill the card and scroll, fading at the bottom while more wait below, the highlighted row kept in view. Where reads the field as the terminal's /cd does (<code>cli/internal/tui/folder_query.go</code>): a word filters the folders sessions used (<code>GET /workspaces</code>), then offers the hosts it starts (<code>GET /hosts</code>, plus the hosts of recent remote folders); text with an @ lists every host; a path or <code>~</code> lists that folder (<code>GET /workspaces?location=</code>) filtered by its last segment, and <code>host:</code> or <code>host:path</code> a folder on the host. The highlighted row's completion shows as ghost text, Tab goes into it, ⌫ after a <code>/</code> goes up a folder. For the mouse, the field's end holds <code>browse…</code> (lists <code>~/</code>) or, in a listing, <code>⌫ up</code> (the listing's parent, as ⌫ after the <code>/</code> does); a listing with nothing typed after its <code>/</code> starts with the folder itself (<code>this folder</code>), and the highlighted child shows a <code>›</code> that goes into it. Which model starts on the default (the providers group's <code>default_profile</code> and its model, what /model sets), with the models of the last few sessions above it until you type a filter; ↑/↓ move, ←/→ step the effort on the <code>EffortBar</code> under the list. What takes the first message, which is optional: Enter starts, but only a keypress that began on that card, so the Enter that picks a model never also starts. The top row is the trail, <code>in <folder> using <model>:<effort>, start! ↵</code>, never wrapping (the model name ellipsizes first), each piece leading back to its card, new pieces fading in once, with a face at its end (<code>var(--face)</code>) for each card's state. Esc steps back a card, clears the field, then closes. Starting is <code>PUT /sessions/{uuidv7}</code> with <code>If-None-Match: *</code> and <code>{kind: "new", workspace, provider_profile, model, effort}</code>, then the message as the session's first input (<code>sendMessage</code>, one input id per dialog, so a retry after a failed send neither makes a second session nor sends twice), and the session opens.</p> <p>Busy faces (<code>moods.ts</code>, <code>live.svelte.ts</code>, <code>LiveFace.svelte</code>) use the saved session fast-mode flag to select an alternate pool of seven speedy kaomoji while preparing, thinking, responding or working. The choice holds for the mood/mode stretch; normal mode, connecting, compacting, stopping and finished-turn faces keep their existing pools. Both clients keep the same pool order and seeded choice. Each fast animation grows its speed trail over four frames in reserved space, with the runner anchored in place (right-aligned within the frame for proportional browser fonts); the shared 500 ms clock is unchanged.</p> <p><strong>Model picker and context meter</strong> (<code>chat/ModelPicker.svelte</code>, sharing <code>ModelChoice.svelte</code>, <code>SearchCard.svelte</code>, and <code>EffortBar.svelte</code> with new session and the command palette, maths in <code>context.ts</code>): one pill shows the session's model (with a small accent lightning bolt when the saved fast-mode flag is on, labelled ‘Fast mode on’ for hover and accessibility), its reasoning effort as individually rounded capsules along the bottom edge (one capsule per effort step the model supports, stepping progressively in <code>--accent</code> lightness and chroma across <code>low</code>, <code>medium</code>, <code>high</code>, <code>xhigh</code>), and a multi-segment ring with the share of the window the context fills ("38%", dimming to <code>--faint</code> once <code>cache_fade</code> expires and turning to the danger colour once the compaction threshold is reached). Hovering the pill opens an interactive context popover (<code>26px</code> storage bar) showing the total window fill (<code>183k / 600k (31%)</code>), a live cache warmth countdown badge (<code>cache warm · 3m 50s</code> vs <code>cache expired</code> from <code>cache_fade</code>), the hatched compaction reserve zone with its threshold label (<code>480k</code>, from <code>GET /sessions/{id}/context?view=summary</code>'s <code>trigger_free_percent</code>), and the tokens remaining (<code>297k until compaction</code>); hovering the bar smoothly expands the 4 category segments (<code>system</code>, <code>messages</code>, <code>tool output</code>, <code>images</code>, with a gentle perceptual curve so tiny segments stay easy to target) across 100 % of the bar and shows the hovered category's token count and share underneath. It shows nothing for the context 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 (<code>Transcript.usage</code>: the reset snapshot's <code>usage</code>, then <code>usage</code> events), so nothing polls. The category split remains an estimate (bytes/4, scaled to measured usage), not an exact tokenizer breakdown. Human <code>shell_command</code> receipts count as messages: only the model-visible 16,384-byte whole-codepoint UTF-8 preview plus command/status/reader metadata is charged, not the 64 KiB web receipt or full retained capture. Referenced receipts are measured after hydration without reusing an earlier incomplete estimate. Additional pages read by the model through <code>output.read_shell</code> remain tool output. The window is <code>GET /sessions/{id}/context-window</code> (<code>effective_context_tokens</code>), read once per model the usage names. The cached count follows <code>cache_fade</code> as the terminal footer's does and is redrawn at each step's time. On the canvas the pill sits in the session window's title bar beside the artifacts, skills, agents, background jobs and workspace pills (<code>nodes/SessionNode.svelte</code>), and dragging any of those five chips off (wherever they sit, title bar or under the composer) is a carry like a window's (<code>tiles/dock.svelte.ts</code>: preview, hint, strips, tab carets, section headers, canvas guides, refusals, Esc or right-click to cancel); letting go makes the window exactly where the preview said, as one ⌘Z step back to the chip (<code>Board.undockChip</code>, <code>nodes/ChipNode.svelte</code>), while letting go on the chip itself or cancelling makes none. It tears the chip off into a compact tethered window (<code>context</code> with the interactive context meter and model selector pill in its body, or <code>artifacts</code>, <code>skills</code>, <code>agents</code>, <code>jobs</code> with their list inline; <code>sessions.readings</code> shares the chat's live usage and entries with an undocked <code>context</code> window), hiding the chip in the chat's title bar (and routing tethers for open terminals, artifacts, or agents from their row in the undocked chip window via <code>Tethers.svelte</code>, falling back to the chat window when docked) until closing (<code>×</code>) its window docks it back in; on a phone it floats at the top edge of the chat (<code>Chat.svelte</code>) alongside the artifacts, skills and jobs buttons, hiding while the reader scrolls or selects text so the bottom dock stays a clean bare handle above the single-beam composer. Clicking the pill opens a fuzzy-searchable palette of all profiles' models below it (current profile first, matching and highlighting characters across provider names and model ids as in TUI's <code>/model</code>, with the effort segmented bar in the footer; clicking a model row or pressing Enter on the first match switches via <code>PATCH /sessions/{id}?view=configuration</code> with <code>If-Match</code>, or queues the choice while the session is busy and applies it as soon as the turn finishes).</p> <p><strong>Slash commands</strong> (<code>commands.ts</code>): the chat reads the session's commands from <code>GET /sessions/{id}/catalog?kind=commands</code> (only those a human may call; before a runtime has loaded, it uses the catalogue's <code>native_commands</code>, as the terminal does, without preparing extensions or booting a kernel). Each chat loads once, then refreshes when slash autocomplete opens or a slash draft regains focus, so a runtime waking or a capability reload does not leave an old menu stuck in the chat. Concurrent refreshes share a request; closing or switching a chat aborts it. Failed reads show an error and retain the previous menu rather than accepting an empty or partial catalogue; opening autocomplete again retries. Loaded commands are authoritative, and pagination rejects a changed loaded revision. Typing <code>/</code> 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 <code>{kind: "command"}</code> 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 answer body owns wheel scrolling at readable canvas zoom, including at its scroll limits; it contains overscroll so neither the transcript nor the canvas moves underneath it.</p> <p>The palette (⌘K, <code>Palette.svelte</code>) finds sessions, then artifacts and commands; as in VS Code's quick open, <code>></code> first narrows it to commands and <code>@</code> to sessions, and ⌘⇧P opens it with <code>></code> typed (<code>searchPalette</code> in <code>App.svelte</code>).</p> <p><code>?</code> opens the keys (<code>Keys.svelte</code>, in the same pane): every key the board answers, grouped (windows, new, sections, boards, canvas, everywhere), kept in step with <code>App.svelte</code>'s keydown by hand, with the canvas group (<code>canvasKeys</code> in <code>keys.ts</code>) shared with the canvas's view bar, and the platform's modifier names (<code>option()</code>, <code>command</code>, <code>shortcut()</code>) in <code>keys.ts</code> too; its "about albedo" swaps in the about card. The palette offers the canvas's own commands (fit all, zoom to 100%, the overview map) only on a board with a canvas, and their keys say <code>no canvas on this board</code> elsewhere. A popover (the palette, settings, new session, the model menu) gives the focus back to what had it once it closes (<code>keepFocus</code> in <code>popover.ts</code>); the menus, the fold, the model pane, settings and about close on a press outside or Escape through one <code>dismissal</code>, and <code>fit</code> keeps each inside its window (or the board) the same way. The (?) between search and settings opens an about pane (<code>About.svelte</code>, 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 <code>THIRD_PARTY_NOTICES.md</code>.</p> <p>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 <code><html></code> as <code>data-sidebar</code>). 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; an <strong>advanced colours</strong> foldout adds per-mode error/removal and addition/success colours, plus an optional speaker (<code>you</code>) override (empty means derived from the accent). These apply to failure states, counts and diff backgrounds without changing syntax highlighting, and travel with theme copy/paste and reset. Older saved palettes receive the existing semantic defaults; choosing a season changes only the three seeds, retaining custom semantic colours and contrast. The inline startup script applies the saved semantic colours before first paint, just as <code>prefs</code> does at runtime; 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). <code>app.css</code> derives every other token from the seeds with <code>color-mix</code> in OKLab (surfaces, chrome, fields, secondary text, hover, dots, shadow tint) and relative <code>oklch()</code> (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 <a href="https://madoromi.moonwit.ch/">madoromi</a>'s (MPL-2.0, credited in the pane and in <code>THIRD_PARTY_NOTICES.md</code>): the defaults are its spring, haru, by day (<code>#f6eff8</code> paper, <code>#584a5c</code> ink, <code>#6928bd</code> wisteria) and by night (<code>#241c26</code>, <code>#e1d1e7</code>, <code>#ddb3ff</code>); 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 (<code>prefs.svelte.ts</code>, <code>albedo.prefs.v4</code>, carrying the theme and dots over from earlier keys) and in an <code>albedo_prefs</code> cookie, which every port on the host shares, so the dev server and each <code>albedo web</code> port open the same way; the cookie wins when both exist, and <code>albedo web</code> 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 <code>fontStack</code>), set as <code>--sans</code> and <code>--mono</code> on <code><html></code> ahead of the default stacks; where the browser offers <code>queryLocalFonts</code>, the installed families are suggested. Text size is two sliders from 10 to 20 px (<code>type.ts</code>, saved as <code>text</code>): chrome text, and, behind the "canvas text at its own size" switch, canvas text; otherwise the canvas follows the chrome. They become <code>--ui-scale</code> and <code>--canvas-scale</code> on <code><html></code> (ratios of the 13 px base, seeded by <code>index.html</code> too), and every font size reads through one: the chrome's as <code>calc(<px> * var(--ui-scale))</code>, a window's from <code>.window</code> in <code>WindowFrame.svelte</code>, whose <code>calc(13px * var(--canvas-scale))</code> is the em origin for the chat, and its own px sizes through <code>--canvas-scale</code>. A new size uses the same form; text inputs that iOS would zoom on focus keep <code>max(16px, …)</code>, and text inside a diagram the script lays out in px (agent cards, the trace's timeline marks) stays at its base size, since that view's own zoom is what enlarges it. The resolved mode is <code>data-theme</code> on <code><html></code> and its seeds are inline custom properties there; a small script in <code>index.html</code> applies both before the first paint, and <code>prefs</code> follows system changes while the theme is <code>system</code>.</p> <p>The chat view (<code>chat/</code>) reads <code>GET /sessions/{id}/history</code> (newest page, then <code>before=</code> as the reader scrolls up, and <code>after=</code> when the stream says something committed) and sends with <code>PUT /sessions/{id}/inputs/{uuidv7}</code>; while the session works, a stop button beside send (or Escape in an empty field) reads the session's <code>run_id</code> and <code>input_order</code> fresh and posts <code>/sessions/{id}/interrupt</code>, so a stop never reaches a later run, <code>{kind: "message", text}</code>; images attach by paste, drop or the composer's picture button (<code>chat/attachments.ts</code>, the terminal client's scheme: each image leaves an <code>[Image #n]</code> 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 <code>images</code>, at most 10); a user entry's images show above its bubble from their history references (<code>/api/sessions/{id}/history/{entry}/image-n</code>). a send stays on screen as pending until its <code>input_id</code> appears in history, and a failed one offers retry with the same id. A turn that failed ends in a quiet row under it, the error's own words (the provider's message out of its JSON body; a click shows all of it): the stream's <code>error</code> event says so as it happens, and a chat opened later reads the session's request records since its oldest loaded entry (<code>context?view=requests</code>, again whenever the session rests, and for each earlier page it reads), a turn whose last model call failed (<code>Transcript.failures</code>); a later call that succeeded clears only a failure a call reported, never one the stream did. There is no retry for a failed turn yet; the daemon has none to call. A stop cancels the messages still queued behind the turn (the daemon never commits them); the interrupt answer's <code>cancelled_input_ids</code> name them, and the chat takes those pending bubbles back out of the transcript and appends their text and images to the composer's draft (<code>Draft.restore</code>: blank-line separated, in send order, images renumbered after the draft's, focus untouched), so a stop loses no words. Your newest message carries a receipt until a reply follows it, drawn as Signal's (<code>chat/Receipt.svelte</code>): 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 <code>sent</code> 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. <code>transcript.svelte.ts</code> 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 or a thought inside it is expanded). Referenced message text is fetched with the page; tool arguments and output only when their run is opened. Markdown is rendered by <code>marked</code> with raw HTML escaped and only http(s)/mailto links (<code>markdown.ts</code>), because transcripts carry model and tool text into a page that can reach the daemon. The session list (<code>sessions.svelte.ts</code>) follows the collection's stream (<code>GET /sessions?scope=roots</code> as server-sent events, <code>followSessions</code> in <code>stream.ts</code>): each <code>activity</code> event patches that session's status and activity in place, so the sidebar, window titles and faces change as the daemon does, and <code>reset</code>, <code>overflow</code> or <code>invalidate</code> 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. Each user message shows its timestamp under the bubble (with the receipt on the newest), and a turn's sign-off row shows its timestamp right before its actions, trace and copy as icons, which appear while the pointer is anywhere over the turn's reply or on the row itself (found from the page when the pointer moves, <code>hover</code> in <code>Chat.svelte</code>), or always on a screen with no hover. What is typed in a composer is a draft kept by session (<code>chat/drafts.ts</code>), 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 (<code>albedo.draft.v1:<session></code>) once typing pauses for 300 ms, and on <code>pagehide</code>, 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 <code>[Image #n]</code> markers; switching boards keeps them.</p> <p>Tool steps and burst clauses read the daemon's derived <code>title</code> from history entries, stream events, or tool progress, falling back to line 1 for older daemons or when no title is provided.</p> <p>Each chat also follows the session's event stream (<code>stream.ts</code>: <code>GET /sessions/{id}?tail=0</code> as server-sent events, read with <code>fetch</code> so it can resume with <code>after_generation</code> and <code>after_seq</code>, 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 <code>active_output</code> and <code>current_progress</code> restore what was in flight. A <code>committed</code> 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.</p> <p>Boards live in the browser's <code>localStorage</code>: the list under <code>albedo.boards.v1</code>, and each board's windows and layout tree (canvas positions, sizes and viewports included) under <code>albedo.board.v3:<id></code>. A board saved only as <code>albedo.board.v2:<id></code> loads as a canvas root with its windows where they were; the v2 key is left alone. Only the active board is mounted; <code>FlowBinder</code> hands each canvas on screen its flow helpers, and anything asked of a hidden canvas (revealing a new window) runs when it is shown.</p> <p>Wire types live in <code>src/lib/api.gen.d.ts</code>, generated from <code>docs/openapi.yaml</code> with the exact <code>openapi-typescript</code> dev dependency. Run <code>npm run generate</code> in <code>web/</code> after changing the contract; <code>npm run generate:check</code> 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 <code>stream.ts</code>. <code>api.ts</code>, 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 <code>tsc</code> of this package (<code>@typescript/native</code> is an alias of <code>typescript@7</code>), and <code>npm run check</code> is <code>svelte-check --tsgo</code>, which type-checks <code>.ts</code> and <code>.svelte</code> with it. The package called <code>typescript</code> is Microsoft's <code>@typescript/typescript6</code> alias, because svelte-check's source mapping and openapi-typescript import the old JavaScript compiler API that 7.0 does not ship; an <code>overrides</code> entry lets openapi-typescript take it despite its <code>^5.x</code> peer range. Drop the alias when both tools support 7's API (TypeScript 7.1).</p> <p>Checks: <code>npm ci</code>, then <code>npm run generate:check</code>, <code>npm run check</code> and <code>npm run build</code> in <code>web/</code>; <code>go -C cli test ./internal/webui/</code> for the boundary. <code>test.sh</code> runs the offline generation check beside the Go API generation check, so install the web dependencies first. The dev shell supplies Node. <code>npm run check</code> is svelte-check followed by <code>scripts/lint.mjs</code>, which turns mistakes the client has made before into rules (no <code>crypto.randomUUID</code>, no <code>autofocus</code>, no reading where a window sits off its ancestors); <code>test.sh</code> runs it when a change touches <code>web/</code>, and <code>--full</code> always. The build is not in <code>test.sh</code>.</p> <h2 id="seeing-the-client" tabindex="-1">seeing the client <a class="anchor" href="#seeing-the-client">#</a></h2> <p>A layout or styling change to the client is judged by looking at it, not by <code>npm run check</code> and not by the code. Start a private daemon (its own <code>ALBEDO_HOME</code> and an <code>ALBEDO_DAEMON</code> copy from <code>test/snapshot-daemon.sh</code>, as in <a href="http://AGENTS.md">AGENTS.md</a>), <code>npm run build</code> in <code>web/</code>, run <code>albedo web --no-open --port 0</code> with that <code>ALBEDO_HOME</code> (<code>ALBEDO_WEB_DIST</code> points it at another build), open the link it prints with the <code>browser</code> binding, and screenshot the states you changed:</p> <ul> <li>a desktop width, the canvas with the panels the change touches open;</li> <li>phone width (760 px or less), which is a different layout (<code>Phone.svelte</code>), unless <code>canvasOnPhone</code> is set;</li> <li>light and dark (settings, theme), since the colours are derived from seeds.</li> </ul> <p><code>npm run dev</code> works too (it proxies <code>/api</code> from <code>$ALBEDO_HOME/daemon.json</code>). Screenshots the <code>browser</code> binding saves without a path go to <code>$ALBEDO_HOME/cache/browser-screenshots/</code>, not the checkout; <code>page.screenshot()</code> returns the path to read. Close the browser, the server and the daemon when you are done, and check <code>ps</code> for ones naming your home.</p> <p><code>test/manual/ux/</code> scripts all of this from an albedo kernel: a seeded home, prepared boards and input helpers, for a review by hand or by a fan-out of reviewers (its README).</p> <h3 id="model-choices-across-chip-relocation" tabindex="-1">Model choices across chip relocation <a class="anchor" href="#model-choices-across-chip-relocation">#</a></h3> <p>A pending model/effort selection belongs to the session, not one picker component. The docked title chip, undocked header, and inline context picker share one in-memory save lane. Dragging or docking a chip keeps its latest unsaved choice, error and in-flight write, and never starts overlapping configuration PATCHes. A failed or unknown write still needs the same deliberate retry; moving a chip does not acknowledge it. Only the mounted picker that owns a completed selection may close its own palette.</p> <p>With no mounted picker, an already issued write settles but no new write starts. Unsaved state remains available when that session's picker returns. Quiescent, unreferenced lanes are removed. This state is not saved to localStorage or the daemon; reloading the page discards unsaved choices.</p>