The browser client is one static bundle served through either dashboard
gateway: aether gui on the user's machine, or
aether-server --web-port over HTTPS on the server's tailnet addresses.
Next.js 16.3.4 produces the static export, while React 19 + TypeScript render
the client runtime, Tailwind v4 and shadcn/ui primitives with CSS variables
provide the base style, selected HeroUI v3 wrappers provide Chip and Tooltip,
and Zustand holds the state. Both gateways use the shared internal/webgate
API and WebSocket surfaces; the server gateway authenticates each request with
Tailscale WhoIs and no browser token.
The visual contract is a VS Code-inspired developer workbench, not an official reusable VS Code component package. It follows VS Code Dark Modern and Light Modern semantics, dense flat panes and compact controls while preserving Aether's routes, capabilities, run states and startup behavior.
The gateways and their transport boundaries are documented in
docs/local-gateway.md. The same bundle serves both, gating machine-local
surfaces on the capabilities descriptor while keeping shared Files,
configuration, runs and terminal surfaces available through either transport.
This guide describes the dashboard's public route, store and component
structure.
Runtime boundary and commands
src/app/page.tsx is a minimal Next App Router page. It loads
src/app/client-runtime.tsx, which is a client component that dynamically
imports src/App.tsx with ssr: false. This boundary keeps the browser-only
store, WebSocket, xterm and Electron bridge out of static prerendering. Next
still emits the document, CSS, fonts and JavaScript assets; the existing
client-side registry and in-memory navigate() state remain the dashboard's
navigation model. Next does not run in production.
make dashboard # Bun install, then Node 22+ and next build (from repo root)
make build # dashboard export, then the Go binaries
cd web && bun run dev -- --port 3000 --hostname 127.0.0.1
cd web && bun run test # vitest
cd web && bun run typecheck # tsc --noEmit
bun run dev starts dev-server.mjs, the development-only Node loopback
server. It defaults to 127.0.0.1:3000, accepts --port/-p and
--hostname/-H, and uses AETHER_DASHBOARD as its gateway target. The
target may be a local aether gui URL or a server-hosted HTTPS dashboard;
local targets also proxy /local, while both targets proxy /api and /ws
with WebSocket support. The proxy preserves the browser Host and Origin
headers so the shared gateway remains the WebSocket origin boundary. Other
requests and Next HMR upgrades go to Next's development handler. The Next
Node inspector attach endpoint is unavailable through this server.
For a local gateway, run it and start the dev server in separate terminals.
The local gateway prints a tokened URL; copy its token query value onto the
dev URL:
# terminal 1
aether gui --port 8080 --url
# terminal 2
cd web && AETHER_DASHBOARD=http://127.0.0.1:8080 \
bun run dev -- --port 3000 --hostname 127.0.0.1
# open http://127.0.0.1:3000/?token=<token-from-aether-gui>
The client moves ?token= into sessionStorage under aether.token, removes
only that query parameter from the address bar, and sends the value as a
Bearer token on HTTP or a token query parameter on WebSockets. The token is
minted per aether gui process and stops working when that process exits.
When AETHER_DASHBOARD points at the server gateway instead, the browser
sends no token: Tailscale WhoIs identifies the phone or development browser
on every request.
?run=<run_id> is read the same way, by takeRequestedRun beside the token
reader in src/lib/api.ts, and hydrate in src/store/sync.ts acts on it
once the runs are in the store: it navigates to that run's terminal, or
leaves the board alone when the run is not one this member was sent. Reading
it removes it from the address bar, which is what makes it one-shot - a
reconnect re-hydrates and must not drag the member back. It is the whole
deep-link contract for both shells; see
local-gateway.md.
Node 22+ is required for a hand-run dashboard build. The complete contributor toolchain and the optional desktop installer workflow are in CONTRIBUTING.md.
Testing on a phone
The shipped phone path is the server-hosted gateway: set web-port, then open
the server's MagicDNS name on a phone joined to the tailnet
(Testing on a real phone). The connection is
HTTPS and carries no browser token; Tailscale WhoIs identifies the phone's
source address on every request. The server-hosted dashboard opens at the
board and does not offer machine-local onboarding, linking or update actions.
For a contributor's loop against a dashboard build that is not embedded in a
server yet, use the development server as a LAN-facing proxy to a local
aether gui:
# terminal 1, on the computer the phone will reach
aether gui --port 8080 --url
# terminal 2, with <lan-ip> that computer's address on the phone's network
cd web && AETHER_DASHBOARD=http://127.0.0.1:8080 \
bun run dev -- --port 3000 --hostname <lan-ip>
# on the phone: http://<lan-ip>:3000/?token=<token-from-aether-gui>
- Binding the LAN address gives up the loopback boundary for as long as the dev server runs. Every device on that network can reach a proxy in front of a gateway with the member's authority on the linked server, and the local bearer token is the authentication: it travels in clear over HTTP, sits in the URL and stays in the phone's history. Use a network you trust, and stop the dev server when the session ends. The shipped boundary is the loopback rule in security.md; this is a development-time exception a contributor opts into by hand.
--hostnamehas to be the address typed on the phone. Next's development server permits only localhost and the hostname it was started on; any other origin needsallowedDevOriginsinweb/next.config.ts.- The token is minted per
aether guiprocess, and the phone needs that one. Nothing else authenticates the local development proxy. - A tunnel or proxy in front of this must preserve
HostandOrigin. One that rewritesHostbreaks every WebSocket - the terminal, the event feed and the attach stream - while plain HTTP keeps working, which makes for a confusing half-broken dashboard. - This serves the Next development build over plain HTTP, not the static export the binary embeds, and it needs the computer awake and on the same network. It is a contributor's loop, not the shipped phone path.
Automated phone coverage is the mobile Playwright project, described in
testing.md.
Build pipeline and the embed
web/next.config.ts sets output: 'export' and distDir: 'dist' outside
development. next build therefore writes a static web/dist artifact:
HTML, _next assets, CSS and copied public files. There is no production Next
server or Node process in the shipped dashboard.
web/embed.go embeds web/dist with //go:embed all:dist, which fails to
compile against an empty directory. The build output is not committed, so the
invariant is kept by two placeholder files:
web/dist/.gitkeepis committed so a clean checkout compiles the Go server before anyone has run the web build.web/public/.gitkeepis copied intodistby every Next build, so emptying the output directory never breaks the next Go build.
.gitignore ignores generated web/dist and Next metadata while retaining
web/dist/.gitkeep. A binary built without running the web build serves the
gateway's "dashboard not built" response rather than a blank page.
The web app manifest
web/src/app/manifest.ts is a Next metadata file, so the export writes
/manifest.webmanifest and links it from the page. A static export refuses to
collect a metadata route that has not declared itself static, which is what the
export const dynamic = 'force-static' in it is for.
It declares display: standalone and start_url: '/', and names four icons
from web/public/icons/: 192px and 512px in both any and maskable. 192 and
512 are the pair Chrome and MDN document; what the installability check
enforces is lower - one any icon of at least 144px - so shipping both
documented sizes is belt and braces. No service worker ships. Chromium's
installability check no longer looks for one; the post announcing the removal
(update-install-criteria)
scopes it to installing "from the menu, since version 108 on mobile and 112 on
Desktop". The dashboard could not cache anyway, because it lives inside the
server binary and has to change with it.
Both mobile browsers read the manifest. Safari has since iOS 11.3
(display, name, short_name, start_url, scope), with theme_color
since 15 and icons since 15.4, so an iPhone's standalone window comes from
the manifest, not from the layout's Apple meta tags. Those tags cover what a
manifest cannot say: the status bar style, which needs
apple-mobile-web-app-capable spelled out by hand because Next 16 emits only
the unprefixed mobile-web-app-capable. web/public/icons/apple-touch-icon.png
stays because Safari prefers it over the manifest icons.
A manifest carries one theme_color where the layout's viewport export carries
one per colour scheme, so both read web/src/app/theme-color.ts and the
manifest takes the dark value. background_color is the separate
iconBackground from that module, #0a0a0a: an installed app's splash centres
an icon on it, and any other value would leave the icon's tile showing as a
square. That hex is deliberately darker than the dashboard's own dark
--background, #1f1f1f - a launcher icon has to read as an object against a
wallpaper - so the splash lightens slightly as the SPA paints over it.
Every icon is generated from web/public/aether-mark.png by python3 scripts/make-icons.py, the same script that writes the desktop app's, and the
output is committed. Maskable icons and the iOS icon are square and full-bleed
because the platform applies its own mask; the rest carry the rounded tile.
CI installs Bun with oven-sh/setup-bun (version pinned in web/.bun-version)
in jobs that run make build or make release, plus a dashboard job that
typechecks and tests the SPA on its own.
The three extension seams
Later waves (the board, terminal, diff timeline, team surfaces) add views and state without editing the shell.
Route registry (src/routes/registry.ts). A route file calls
registerRoute('board', Board) at module scope; src/routes/index.ts imports
it once for that side effect. The center view looks the current route up by
name and renders it with route.params. Navigation is a store action -
navigate('terminal', { runId }) - rather than a URL router: Next supplies the
document and static assets, while the dashboard remains one client screen and
every surface uses the same action.
Store slices (src/store/). One Zustand store composed of slice creators,
one file each (server, workspaces, runs, members, terminal, board,
palette, approvals, presence, cost, timeline, diff, shell,
local, ui). A new feature adds a slice file and one spread in
createRootStore. Slices are typed against the whole root state, so a slice
may read another's data. Only view preferences (theme, sidebar width and
collapse state, activeWorkspace, grouping, dismissed update versions,
terminal zoom) are persisted; persistedUi in store/index.ts is the list
that decides. Server data is always re-fetched.
activeWorkspace is the scope every surface reads. It lives on the ui
slice and names the workspace the sidebar's run list, the board, launches,
templates, budget dialogs and the activity feed all act on. Empty means "all",
which is what the board falls back to before hydration has named one.
setActiveWorkspace carries an open workspace route along with it, so the
switcher can never say one workspace while the view beside it acts on another,
and navigate('workspace', ...) makes the workspace it opens the active scope
for the same reason.
Derived data (the sidebar's grouped run list, the attention-ordered run list)
lives in
src/store/selectors.ts as pure functions over a narrow input type, wrapped by
memoizing hooks in src/store/hooks.ts. Selectors that build new arrays must
not be passed to useStore directly. A view that owns its own derived shape
keeps it beside the view instead (src/routes/board/selectors.ts). The
sidebar and board inputs carry the pending-approval run set rather than the
raw inbox: usePendingApprovalRuns derives it once, subscribing on a stable
string key, so a run holding a pending request presents as needs-attention
everywhere the selectors are read - the sidebar, the palette, and the run
lists - while a byte-identical inbox refetch re-renders nothing.
Slots (src/components/slots.tsx). Where a route registry is too coarse -
something belongs inside a surface another ticket owns - the surface renders
<Slot name="..." /> and contributors call
registerSlot(name, id, Component) at module scope. Registration order is
render order, id keys the render and makes a double registration an error.
The slots that exist:
| Slot | Props | Where it renders |
|---|---|---|
card:badges |
{ run } |
the run card's title row, after the paused and unseen markers |
card:chips |
{ run } |
the wrapping metadata row after the harness, branch and last-commit readout |
card:footer |
{ run } |
the card's bottom row, right of the owner and timestamp |
statusbar |
none | the status bar, for refresh, shortcuts and other live contributors |
The statusbar Slot is mounted once, even when narrow layouts collapse its
details. The command palette is not a status contributor; it has one
independent host in AppShell.
Card slot content may render its own links and buttons; the article's pointer
handler ignores interactive descendants, so those controls stay interactive.
Conflict chips, watcher avatars and approval badges belong in these slots, not
in run-card.tsx.
Sidebar
src/components/shell/sidebar.tsx owns the resizable workspace/run sidebar
beside a persistent 48px activity rail. The rail carries existing navigation,
capability gates, accessible labels and tooltips, a 2px active indicator and
overflow when all destinations do not fit. The adjacent sidebar defaults to
320px and is constrained to 320-520px. Older saved widths below the minimum
are clamped when rendered. Runs, the attention count, New run, and Status /
Member stay on one row; a phone drawer remains bounded by its viewport.
The sidebar is a workspace switcher over a flat list of that workspace's runs.
There is no run tree: one workspace is in view at a time, so the runs group
instead by state or by owning member (groupBy, persisted). Rows and headers
are compact rather than a lower navigation card.
Every rendered group header is a disclosure button with its run count. When
grouped by Status, every status header toggles its own member rows; Done
starts collapsed and every other status group starts expanded. When grouped by
Member, every member header toggles its own rows and every member group
starts expanded. Disclosure state is local to the current grouping mode, so a
collapse in Status does not carry into a Member group with the same key.
At 1000px and narrower the adjacent workspace/run pane collapses into the persistent activity rail, which exposes Expand sidebar without changing the stored preference. The width handle remains a keyboard and pointer window splitter (see Keyboard and focus).
At 640px and narrower the expanded pane is a modal drawer instead: a Radix
Dialog over a scrim, dismissed by a tap outside, by Escape, or by any
navigation it makes, with focus trapped inside it while it is open. The
activity rail travels inside the drawer, so a surface is still one tap away
while the run list is up, and the 48px column the drawer leaves behind keeps
the center view from reflowing under the scrim. The route itself is what
closes the drawer, so a run row and a rail link both take the drawer away
without either knowing it exists. There is no splitter in the drawer: the
viewport sizes it, capped at the width of the screen less the rail.
Being a dialog, the drawer also stands the shell's global keys down while it
is open, the same way every other modal does (see
Keyboard and focus) - the palette, n and the g
chords are unreachable until it closes. Mod+B is the exception: the drawer
answers that one itself, because it is the key that opened it, and a member
who opened the drawer from the keyboard must be able to close it the same
way.
- The switcher sits above everything it scopes, and appears only when there is a choice: a single workspace renders as a plain label with its base branch under it, because a picker with one option is a control that cannot be used.
- The runs come from
sidebarRuns/sidebarGroupsinsrc/store/selectors.ts, filtered toactiveWorkspaceand sorted worst-state-first, then most-recently-changed-first, so what needs a human is at the top of whichever group it is in. An empty scope shows every run, which is what the list falls back to before hydration has named a workspace. - The shared
RunListkeeps visible run labels to two lines, while each row button retains the full label as itsaria-label. - The attention badge counts, it does not navigate. The runs below are already sorted worst-first, so the number is for a scrolled sidebar or a stall that landed while the member was elsewhere in the app.
- The header carries New run and the grouping. New run opens the launch
form (gated on
canLaunch). Grouping is a two-segment control, Status and Member, witharia-pressedon the current one, so the pressed segment is the state and the other one is the action. - The activity rail reaches every other view, with the active route marked
by
aria-current. Board and All runs lead it, and scope-wide entries keep the method or local-verb capability gates that power their views. - The rail and palette share the scope-wide surface list from
src/lib/surfaces.ts, so a surface cannot be named one thing in navigation and another in the palette, and neither can forget its gate. Approvals carries the pending count in its accessible name; the status bar keeps its own copy for when the member is looking elsewhere. - A nav entry is named what the view it opens is titled, including
routes/workspaces/as "Manage workspaces" rather than "Workspaces".
Files view
src/routes/files/ is the Files explorer and editor. It combines each visible
workspace's base branch, live run checkouts, and the authenticated member's
own persistent configuration roots from config.roots. files.tree,
files.read, files.write, files.diff, and the config.* methods are
available through both the local SSH-backed gateway and the server-hosted
WhoIs gateway. Directory requests are lazy and cached in
src/store/files.ts; file content comes from files.read or config.read.
A live run can switch from File to Diff vs base.
The tree is a browse pane beside the editor at medium widths. On narrow screens it is the first view; selecting a file opens the editor and Browse returns to the tree. CodeMirror provides syntax highlighting for JSON/JSONC, JavaScript/TypeScript, Markdown, Python and TOML, plus find/replace. The editor renders complete UTF-8 text without NUL bytes up to 64 MiB; binary and oversized responses remain read-only.
The action label states the write target: base files show Commit to
Saves are explicit (Save, Commit to
Title bar
src/components/shell/title-bar.tsx renders the browser and Electron
title/command bar at 35px. The command center names the active workspace and
opens the existing command palette through togglePalette(true). It is the
top edge of the shell, so it pads itself with env(safe-area-inset-left/right)
against a landscape notch and grows by --safe-top under the status bar an
installed iOS app paints over the page, padding its controls back below it.
Every inset is 0 on a screen without one, and the Electron traffic light inset
above wins where it applies.
In Electron the window is frameless, so the SPA draws the bar and its native
controls: on Windows and Linux, minimize, maximize/restore and close are wired
to window.aetherDesktop.controls; macOS keeps its native traffic lights and
the bar reserves 78px for them. The bar is -webkit-app-region: drag and
every button is no-drag.
The bridge carries one more thing the SPA cannot do for itself:
window.aetherDesktop.chooseFolder() opens the shell's native directory
dialog, parented to the asking window - a sheet on macOS, modal to the window
on Windows; a Linux portal chooser runs out of process and is neither, which
is why the caller disables its button while one is open - and resolves to the
chosen absolute path, or "" when it was cancelled. A window it cannot
resolve rejects with no window asked for the folder dialog instead, so a
caller can say what went wrong. It is optional on the type for the same
reason shellVersion exists: a shell built by an older aether gui build
does not have it.
App.tsx mounts it above the whole app, the ConnectionError page included:
that page replaces the workbench, while the titlebar and native controls
remain available in a total failure state. Browser tabs have no Electron
bridge but still show the command center.
On the first desktop launch, LaunchSplash covers the window with the
original shooting-star scene, Aether mark and VT323 wordmark. It stays for at
least 600ms, leaves after hydration or failure, and has a 2500ms cap followed
by a 260ms fade. The cap prevents a failed connection from holding the window
controls indefinitely. Browser tabs, reloads in the same window session and
prefers-reduced-motion: reduce skip it. See styles.md for the
scene's motion details.
Window size and overflow
The shell is a fixed column - title and command bar, update prompts, activity rail with its adjacent workspace/run sidebar and center view, and a status bar
- and nothing in its own chrome scrolls sideways. The two bars are 35px and 22px for a mouse and grow for a finger; see the tokens below. A control pushed past an edge is unreachable, not merely off screen, so every row states what gives way first.
desktop/main.js sets minWidth: 960 and minHeight: 600. That is the size
the desktop rules are designed against; a browser tab has no such floor, so
the same rules degrade below it rather than break. A phone is the far end of
that: src/app/layout.tsx exports the viewport the shell needs there.
width=device-width, initial-scale=1- the page is laid out at the device's own width rather than a desktop-sized canvas scaled down.viewport-fit=cover- the shell paints under the notch and the home indicator, and the chrome that touches those edges pads itself back out withenv(safe-area-inset-*): the title bar sideways and downwards, the status bar and its details popup downwards, the sidebar drawer on all three edges it reaches, since it is the one surface that spans a screen corner to corner, and the row holding the activity rail on the left, which is the edge a landscape notch covers when the drawer is not up. The top inset is the one a surface away from that edge also has to read, because an installed iOS app asks for ablack-translucentstatus bar and gets the whole screen: it is--safe-topinsrc/index.css, and the title bar's height, the palette's drop from under it and a top-anchored dialog's 1rem gap all count it. A surface that pads itself keeps painting to the edge and insets only what it holds, so the notch shows the bar's own colour rather than a gap. Every inset is 0 where there is none, so nothing guards them. Toasts sit above the status bar rather than against the screen edge, so their offset adds the inset to the bar's own height - and it has to be given tosonnertwice, asoffsetand asmobileOffset, becausesonnerswaps to the second below 600px and otherwise falls back to a 16px default that lands inside the bar.interactive-widget=resizes-content- on a browser that honours it (Chrome and the Android WebView; iOS Safari does not), the soft keyboard shrinks the layout viewport instead of sliding the page under itself. That is what everydvhin the app - dialogs, the palette, selects, menus, the status popup - is already sized against, so they all shorten when the keyboard opens. Nothing in the shell usesvh. Where it is ignored the layout viewport does not move, which is why dialogs also anchor to the top belowsm(see the end of this section).themeColorperprefers-color-scheme- the browser reads it before the SPA has applied the member's stored theme, so it follows the OS scheme rather than the app setting.
A layout that only changes size belongs in CSS. The ones that mount
different elements for a finger than for a mouse - the run header's menu, the
diff timeline's disclosure, the activity filter bar - ask useMediaQuery in
src/lib/hooks.ts instead, and every edge it asks about is a named constant
in the same file: coarsePointer is the CSS variant below asked from
JavaScript, and belowSm and belowMd are Tailwind's own 640px and 768px a
pixel short. New code reads an edge from there rather than writing a query,
so a layout that stacks in CSS and a layout that stacks in JavaScript cannot
disagree about where. Two call sites predate the hook and still hold their
own literals - shell/sidebar.tsx and shell/status-bar.tsx - and move onto
it in a follow-up.
Touch density is one variant, defined once. src/index.css declares
@custom-variant coarse (@media (pointer: coarse)), and a control that a
finger has to hit carries its touch size beside its desktop one - for example
size-[22px] coarse:size-11. It answers for the primary pointer, so a touch
laptop with a trackpad keeps the desktop density. Under it the Button
sizes, CommandItem, DropdownMenuItem, the CollapsibleTrigger, the
Select trigger and its options, the dialog close, the palette trigger and
input, the status bar controls, the sidebar run rows and the sidebar's own
buttons, the run-list title, the files tree rows and the approvals controls
grow to 40-44px, and the terminal toolbar row grows with the buttons in it.
Desktop density is untouched.
Use this variant rather than a new breakpoint or a per-component pixel value.
The two bars are tokens, not repeated numbers. --title-bar-height and
--status-bar-height are declared in src/index.css and redeclared once
under (pointer: coarse), where they become 48px and 44px so a 44px control
fits inside them. A row that has to line up with a bar reads the token - the
title bar and the sidebar's workspace switcher, the status bar with every
control and readout in it, the command palette's drop from under the title
bar - and so does every offset measured from one: the update banner cap and
the toast offset. --safe-top is the third token of this kind: the top
safe-area inset under a name, so that a surface measuring from the title bar
can add the same amount the bar itself grew by. It carries a 0px fallback
because a bare env() in a browser without it would void every calc()
height that reads it. Add a coarse size to a control in a fixed-height row
only together with the row, or the control grows out of the bar that holds
it.
Dialogs anchor to the top (1rem plus --safe-top) below sm and centre from
sm up. Where interactive-widget is ignored, a centred fixed dialog sits
behind the keyboard; anchored to the top it stays in the visual viewport, and
a dialog taller than the screen is clamped to the viewport less that gap and
scrolls inside itself. sm is a width breakpoint, so a desktop window narrower than
640px is treated as a phone here too.
- Update notices keep the message, status icon and action hierarchy visible. Their actions become a narrow-screen grid and return to a desktop flex row; technical output is bounded and expandable, and the dismiss control remains keyboard reachable.
- The status bar keeps connection state, the theme control and the
status-slot contributors reachable at every width. Narrow layouts put
secondary readouts in a bounded, keyboard-reachable popup; wide layouts
expand them inline. The single status Slot remains mounted while details are
collapsed - a contributor owns a keyboard shortcut of its own, which is why
the popup is a
Collapsiblewith its own dismissal rather than a Radix overlay that unmounts when it closes. It dismisses on Escape and on a pointer down anywhere outside it, unless a dialog above it owns the key; it takes Escape before the shell's own does, so dismissing the popup on a run does not also leave the run (see Keyboard and focus). The popup also writes out the facts a pointer reads from a hover: the disk breakdown, the protocol version and what this machine is linked to. Tooltips andtitlestay hints for a pointer, never the only copy of a fact. The bottom-left Usage entry is one mounted reader: it remains compact and reachable at narrow widths and sits beside the version readout on wide screens. It callsaccount.usagefor the authenticated member, polls only while the page is visible and live, and refreshes on reconnect, focus, visibility and an explicit Refresh action. Opening the popover loadsaccount.listfor the own/shared account selector; changing that selection clears the previous values before reading the new account, and responses from superseded selections are discarded. Claude and Codex are always represented in the popover. The display uses only measured windows: an expired reset window is omitted until a new measurement arrives, transport failures mark saved values stale, and authorization failures clear them. Older servers that report method-not-found are explained without a retrying poll loop. Pi, OpenCode, OMP/custom harnesses, API-key usage and billing history are explicitly outside this read-only surface; credentials remain server-side. - The run header gives its first section two lines: the title and task disclosure, then state, harness/mode and branch metadata. The second section holds only the run-detail tabs and actions. Desktop actions all appear from a 512px header width, with icon labels expanding from 1024px; narrower headers retain More. Metadata and tabs scroll inside their own regions before either or the actions become unreachable. On a coarse pointer the action group becomes one Actions button and every verb moves into its menu, where each carries its full label at finger size. Six 44px buttons do not fit across a phone, and the mouse answer to a narrow row - 22px icons with the label in a hover tooltip - is six unnamed icons to a finger. Hand off is in the same menu.
- The board uses one column on narrow screens and three columns from the
lg/1024px breakpoint, with compact flat run cards and vertical scrolling on small screens. State labels remain visible; empty, loading and error panels use the same bounded surface hierarchy. - The workspace/run sidebar collapses at 1000px and narrower into the persistent 48px activity rail, which exposes Expand sidebar without changing the stored preference. At 640px and narrower its expanded pane is a modal drawer over the main view, taken away by a tap outside, by Escape or by the navigation it makes, rather than a pane that stays over the run it just opened.
Data flow
connect() in src/store/sync.ts owns the whole lifecycle. One round of HTTP
fetches hydrates the store (server.info, workspace.list, member.list,
run.list, run.overlaps, and GET /api/v1/capabilities), then /ws/events
is the only thing that changes it. Hydration also repairs the scope: an unset
activeWorkspace, or one naming a workspace that is gone, falls back to the
first by ID rather than leaving every scoped surface pointed at nothing. The capabilities fetch may fail without
failing hydration - a legacy gateway has no such endpoint - and the store
then holds null. The snapshot also seeds the board's paused map from each
run's wire paused field, skipping runs that do not carry it.
- The subscription is established first. Hydration starts only once the
server acknowledges it (
{"ok":true}), which is also when the client calls itself live. Otherwise a change between the snapshot and the subscription would fall in the gap and never be delivered or replayed. - Events arriving during a fetch wait in the queue, and are applied once the snapshot lands, so an older snapshot never overwrites a newer event.
- Events are applied one at a time, in sequence order, each fully resolved before the next begins. The cursor is a single number, so it must never move past an event still waiting on a fetch.
- The stream subscribes live on the first connect (the fetch behind it
provides the current state) and replays from the highest applied
seqon reconnect, with jittered backoff. An event at the cursor is ignored, so a replay is idempotent; one strictly below it means the server's event log restarted (a recreated or restored data dir), and the client zeroes its cursor and takes a fresh snapshot rather than silently dropping everything the new log sends. - A reconnect with no cursor cannot replay - on a quiet server nothing has
advanced
seq- so the client re-fetches the snapshot instead of subscribing live and silently missing the outage. - Server-gateway reconnects also refresh the snapshot when replay is possible: Tailscale may identify the new connection as a different member. Only a local gateway's fixed identity can reuse replay without that refresh.
- A failed hydration retries on the same backoff, and the affected panes say the server is unreachable rather than animating skeletons forever. That generic copy never overwrites a more precise error already recorded.
- A total failure replaces the shell with one error page. When nothing has
hydrated and an error is recorded,
ConnectionErrortakes the window instead of an empty sidebar and an empty board behind a toast. Which hop failed picks the copy:networksays this machine has no network at all and names wifi and a VPN,serversays the server did not answer through the selected transport,gatewaysays the localaether guiprocess stopped answering,tailnetsays the phone got no answer from the server that serves it the page, and an access refusal preserves the gateway's own reason. A fetch that got no answer at all isgatewayonly on the desktop origin, where that process can be restarted; on the server gateway it istailnet, because the page came over the tailnet and there is no local process to blame and no wifi advice to give. The capabilities probe records which gateway serves the page before hydration runs, so the first failure is classified too. The gateway's message appears in an initially open "Technical details" disclosure, and the page suppresses the toast that would otherwise repeat it. Retry clears connection state and remounts the subscribe-and-hydrate cycle rather than reloading the page. - A local token refusal is reported as access failure, not an unreachable
server.
connectreadsGET /api/v1/capabilitiesbefore it opens the stream, and a401there means the local gateway refused its token. The store records that refusal verbatim, stops retrying and tells the member to open a fresh URL fromaether gui. The token is minted per process and held in the tab's session storage, so a bookmarked URL, a second tab or a restartedaether guineeds a newly printed URL. On the server gateway there is no token check: WhoIs identifies the source address on every request, while a tagged node is denied and an unavailable identity service reports its own403or503refusal. - The sockets reopen on a foreground or network return. Both
connectEventsandconnectAttachsubscribe tovisibilitychange(visible) andonlinethroughonWakeinsrc/lib/stream.ts. A phone freezes a background tab's timers and drops its sockets, so a tab coming back from the pocket would otherwise sit out the remainder of a wait that caps at 30 seconds. Either event clears the pending retry timer and resets the backoff unconditionally - a tab that was away cannot know how long the failure lasted - and reopens when there is no socket. The two differ on a socket that is still there: a foreground return leaves it, since tearing a working subscription down would replay the log for nothing and the tab being hidden said nothing about the network.onlinedid, so it replaces the socket whatever state it reached, an acknowledged one included. A wifi-to-cellular switch leaves exactly that socket half open: the browser goes on reporting it as connected, the store goes on saying Live, and no close ever arrives, because the server's end sees the FIN and the phone does not. If an attach is still inside its replay boundary,onlinefirst cancels its serial parser and drain with an explicit cancellation signal, clears the partial operations, and drops the socket while keeping the terminal hidden. The replacement attach starts one fresh hidden replay, so no incomplete prefix can become visible and a stale completion cannot reveal the old host. A final replacement refusal settles the gate before showing the server's error. The event is rare enough that one resubscribe fromlastSeqand one re-attach with its replay are the cheaper mistake. The 30 second cap stays for genuine outages, and an attach the gateway refused - or one parked on asession endedclose, whose transcript cannot change again - is an answer rather than a failure, so neither event re-asks it. - The hydration retry wakes as well. A re-hydration that fails after the first good one leaves a cursor to replay from, so the reopened stream goes live without re-fetching and nothing else would restart that timer. A wake with a retry pending clears it, resets its backoff and re-fetches at once; a wake with none re-fetches nothing.
- A
run.statusevent for a run the client has never seen fetches that run before the event is applied, which is what keeps two quick transitions of a brand new run in order. If the fetch fails the event is unresolved: the cursor stays put and a fresh snapshot is taken to repair the store. Arun.deletedevent removes the run from every connected dashboard; arun.statusevent that raced a local deletion treats a404re-fetch as resolved instead of forcing a redundant hydration. An event naming an unknown workspace re-fetchesworkspace.list, because workspaces arrive only by fetch and a run under an unknown one would render nowhere. An event whose actor is not in the members map re-fetchesmember.listfor the same reason: nomember.*event exists, so a teammate who joined after hydration would otherwise render as a raw ID forever. Aworkspace.timelineentry of kindhandoffre-reads its run the same way, because a handoff publishes norun.statusevent to carry the new owner.run.title,run.protectedandrun.archivedfollow the same fetch-first rule on a run the client has never seen. Aserver.updateevent lands in theserverslice, which feeds the update prompts.
The capabilities descriptor is the transport seam. The store holds the
GET /api/v1/capabilities answer (gateway, methods, ws, and local
where the gateway has local verbs - the server gateway omits it), and
useCapability() in src/store/hooks.ts wraps it as three predicates -
hasMethod, hasLocal, hasWS - with methods: ["*"] meaning everything
and a missing local meaning no local verb is available.
When the descriptor is null (a gateway that predates the endpoint), the
fallback is the read-and-steer method set every gateway has always served,
events and attach sockets, and no local verbs, so an unknown gateway
degrades to monitoring rather than to "everything". Views gate on these
predicates rather than sniffing the URL, which is what lets the same SPA
render against a gateway with or without the local surfaces
(docs/local-gateway.md).
Capability is half the gate; the caller's role is the other half.
Transport capability answers what the gateway can carry, not what this member
may do. useSelfRole() and useIsAdmin() in the same hooks file read the
role off server.info's member record, and every admin affordance needs both
predicates: the gateway can carry the method and the caller holds the admin
role. Reads are gated on capability only, so the roster is reachable on the
server-hosted dashboard as well as through aether gui; the server remains
the authority and checks every call again. A non-admin cannot edit membership
or roles but can grant or revoke access to their own agent account.
Every request goes through src/lib/api.ts - the only module that knows route
shapes, gateway authentication and error decoding. It carries exactly the
methods the views call; the team-feature methods arrive with the tickets that
use them. Every call is a POST /api/v1/<method> bar three GETs - the diff
tab's patch text, the status bar's disk number, and the capabilities probe -
because those read a working tree, a filesystem, and the gateway descriptor
rather than RPC methods. aether gui sends its per-process token as
Authorization: Bearer on HTTP and as ?token= on WebSockets. The
server-hosted gateway sends no token; WhoIs authenticates each request.
The status bar's disk gauge renders when server.info carries a disk
object (used_bytes, total_bytes). That field does not arrive with
server.info: protocol.ServerInfoResult is shared with the CLI and frozen,
so the gateway serves the number on GET /api/v1/disk and the team refresh
writes it onto the stored info, which is the gauge's only reader. The field
stays optional and the gauge stays hidden if the read fails. What statfs
answers is the whole filesystem holding the data directory, not the directory
itself, and the gauge is labelled as that: it is the number that says whether
the box is running out of room, and claiming it as Aether's own usage would
be an invention.
account.usage is the quota RPC used by the status-bar popover. Its params
are {account_member_id?: string, refresh?: boolean} and its result is
{account_member_id, providers} with independently decoded Claude/Codex
provider rows (status, windows, optional plan, updated_at, retry_at,
error, and checked_at). The provider endpoints are subscription services
whose response shapes can change; the server owns credentials, fixed provider
hosts, bounded fetches and cache policy. The dashboard does not refresh
tokens, run provider CLIs, or infer billing/history, and an older server that
does not know this method is shown as needing an update rather than polled
forever.
Board
src/routes/board/ is the default center view: the active workspace's run
cards in the three buckets the GUI spec copies from Orca. needs-attention is
Needs You - the agent is waiting for you, or the run stalled, and the reason
strip on the card says which - queued/provisioning/running is Working,
and completed plus the final statuses are Done. An active run whose approval
request is still pending also presents as needs-attention on the board and in
the sidebar - the pause is invisible in the domain status, so runState takes
a pending flag fed from the approval inbox. Cards sort by last state change,
newest first.
The board header wraps its title, run count Chip, descriptive copy and toolbar.
Its grid is one column on narrow screens and three columns from the
lg/1024px breakpoint, with flat bordered columns, readable state headers and
bounded card content. The primary run-card button contains the state and
title/task metadata as bounded previews, each capped at three lines; the button
keeps the full runLabel as its accessible name, and the shared RunHeader
exposes the full task through View full task. An empty workspace
shows one "Ready for a task" panel and a primary New run action rather than
three repeated empty columns. Loading uses delayed skeletons, and hydrated
empty buckets say "Nothing here." without confusing an in-flight request with
an empty result.
The card's article remains a pointer surface for noninteractive metadata, while
interactive descendants and any non-collapsed text selection are ignored by
the article handler. Branch text is explicitly navigation-exempt so it can be
selected or copied without opening the run. The branch metadata row shows the
full branch in its title and has a copy button beside it.
Reaching for the branch is therefore not a way into the run; the rest of the
card is. Copying goes through src/lib/clipboard.ts, shared with
CopyableCommand, because an origin without navigator.clipboard - plain
http, an older engine - has to fall back to selecting the text for a manual
copy rather than failing quietly.
Two things the buckets do not come from the run status alone:
- Paused is a badge, not a bucket. A paused run still reads
runningin the domain enum, so the wireRuncarries apausedfield the gateway decorates from the scheduler onrun.getandrun.list, never derived from the stored run. The hydration snapshot seeds the board's map from it (seedPaused, skipping runs without the field - a legacy gateway), and livepause/resumeentries on theworkspace.timelineevent stream keep it current (pausedFromTimelineinsrc/store/board.ts). - Unseen marks a run whose state changed since someone acknowledged it.
An ack records the status and the change time, because
stateChangedAtis recomputed from the run's timestamps on every fetch and can move backwards - a needs-attention run has nofinished_at, so a re-hydration falls back tostarted_at, and a time-only comparison would mute exactly the card that needed the human. Acks live in the board slice and are app-wide:navigate()acknowledges whenever the route it is given carries arunId, so every surface that reveals a run mutes its sidebar row and its board card together, and the board header marks everything at once. They last only as long as the tab - nothing is acknowledged when the page loads, so a fresh tab shows what is waiting rather than remembering that yesterday's you looked at it.
Archiving hides a finished run from Done without deleting it. A run
carries archived_at/deletes_at once archived. Every hide guard -
board(), sidebarRuns(), and the attention count - drops it once its
status is also final (isArchivable: merged, abandoned, failed,
interrupted); a route that opens a run by id is untouched, since it reads
the run map directly. The Done ColumnHeader grows an "Archived N" toggle
once N is over zero, swapping the column's content to those runs, and
resets to Done the moment the last one leaves. Each archived card, and the
run header for one, show deletesInLabel(deletes_at) (src/lib/format.ts):
"deleted today" under 24h (past due included), "deleted in 1 day" under
48h, then "deleted in N days".
Clear done archives every eligible Done card at once. The Done
ColumnHeader grows a "Clear done" button, and the palette carries the same
action as "Clear done runs"; both open the one confirm dialog
(ClearDoneConfirm, src/routes/board/clear-done-dialog.tsx) over a plan
clearDonePlan() (src/lib/commands.ts) snapshots when it opens. Eligible:
isArchivable, not already archived, and killable by the caller. The
dialog states what it will archive and, by count, what stays and why -
still completed and awaiting Close, or not this caller's to kill. On
confirm, runClearDone() archives the eligible runs one at a time, never
Promise.all - the server has a single SQLite writer - and treats a
CodeNotFound refusal as success (the run is already gone) by removing it
locally instead of retrying. It reports one toast: "Archived N runs", or
"Archived N, M failed: " plus the first real failure's message verbatim.
Reason and paused on the wire
The Needs you reason survives a fetch. protocol.Run carries reason -
the last run.status reason, persisted with the run and sanitized
server-side - so a run that was already in needs-attention when the tab
loaded still says why: waiting for your input and its siblings when the
agent reported it, stalled: ... when the silence heuristic parked it.
toRecord in src/store/runs.ts prefers the wire reason and falls back to
the previously stored one only when the fetch omits it and the status has
not changed (a legacy gateway); a live run.status event still overwrites
it with the event payload's reason. An approval pause keeps its fallback: a
card with an empty reason uses its oldest pending request's action as the
summary.
The paused badge hydrates from the same snapshot. With paused on the
wire (above), a reload shows the badge for a run paused earlier, and the
palette offers the right one of pause/resume. Against a legacy gateway
whose runs carry no paused field the state stays unknown until a live
workspace.timeline pause or resume arrives, and neither surface offers a
verb rather than offering the one the server would refuse.
Commands: one list, two ways to reach it
src/lib/commands.ts holds every verb the dashboard can perform - the run
verbs (pause/resume, send a message to the agent, close as merged or abandoned
at any stage that holds a record, kill, delete, archive/restore,
protect/unprotect, relaunch, pull branch, hand off) and the board verbs (open
the board or the list, launch, launch from a template, mark all seen, clear
every eligible run out of Done) - as data: an id, a label, an icon, the
capability gate, and the call itself.
useCommandRunner() performs one and reports the outcome the same way
everywhere: gateway verbs toast their past-tense name or the server's refusal
verbatim. Deleting a run also removes it from the local run map after the
server confirms deletion; archiving, restoring and protecting call the
gateway and nothing else, leaving the store for the run's own run.archived
or run.protected event to update - two clients racing the same run cannot
have an older RPC response overwrite a newer event.
Archive/Restore are gated on isArchivable(status) (src/store/runs.ts;
merged, abandoned, failed, interrupted, never completed) plus the
kill permission and the run.archive capability; neither confirms, since
archiving is reversible. Both join primaryCommands in
src/components/run-actions.tsx, so Archive sits on the header next to
Delete. The palette resolves its focused run from the run map by
route.params.runId rather than the attention list, so an archived run's
own page still offers Restore even though attention has stopped listing it.
- The command palette (
src/components/palette/) is the cmdk palette:⌘Kon macOS andCtrl+Kelsewhere, anywhere in the app (see Keyboard and focus), or the command center in the titlebar. Both entry points use the existing toggle action. The palette is mounted exactly once byAppShell, independently of the status Slot, and its quick input is top-centered directly under the 35px titlebar, max 600px, with compact bounded rows before the dialog portals to the document. It jumps to runs and workspaces - opening a workspace also makes it the active scope, so the sidebar and the board follow - and opens a steer request for the run the center view is showing, or any run-detail tab, since it keys onroute.params.runIdrather than on a route name. From the board there is none, so reveal a run first. Its "Go to" group issrc/lib/surfaces.ts, the same gated list the activity rail renders. - Visible buttons, so nothing important is reachable only by a shortcut:
New run in the sidebar header, in the board header and in the notice an
empty board shows in place of its columns; every view in the sidebar nav;
and the run action bar (
src/components/run-actions.tsx) in the header of every run-detail tab, which is where the run verbs live for a member who has not learned⌘Kyet.
Two things the buttons add. A Command carrying a confirm field - kill,
delete and both close actions - opens a dialog naming the run before it runs;
the palette does not ask, because a palette item is already several
deliberate steps (open, type, select) away from an accident, where a button
is one click. And the bar locks while a verb is in flight, showing a spinner
on the one running, or on the More trigger while any verb is in flight: a pull shells out to git fetch over SSH and takes seconds, and a
second click would race the first for the same ref. Buttons
also take the command's short label and keep the full sentence as their
tooltip, because the action bar is intentionally compact. The overflow menu
has the room, so it prints the whole label instead.
hand off and protect need the run's owner or an admin. Before hydration the
caller's own record has not arrived, and the mirror answers yes rather than
making the shell's buttons appear a beat late. Pull is the exception that is
not a question for this policy at all: it is the desktop gateway fetching a
published run branch into the repository on this machine, so it answers to
hasLocal('pull') alone. It does not refresh a workspace base or authorize a
mirror source.
The forms
The three verbs that need prose open a dialog rather than calling straight
through: launch, post a steer request to the agent, and launch from a template.
The message form is inject-dialog.tsx over run.inject; each submission
includes a caller-generated idempotency_key, which stays the same when the
request is retried and changes only after the message payload changes or the
submission succeeds. The server records that legacy method as a Run Room steer
request, so it follows the controller lease, 45-second queue, moderation, and
receipt rules. The launch and message forms are a store dialog
(openPaletteDialog on the palette slice) hosted by AppShell through
components/palette/dialogs.tsx, so a button on any surface opens one by asking
the store, with no dependence on the palette or the status bar being on screen.
The template form's open state lives with
CommandPalette in index.tsx instead, because the store's dialog union
knows only the other two. It lists the active
workspace's templates over template.list and starts the run with
template.launch (both on lib/api.ts like every other call), then reveals
it.
The launch form asks for an account, a task, an agent and a mode. account.list
puts the caller first, followed by accounts explicitly shared with them. A
shared selection makes agent.list return that account's custom definitions
and sends its ID as account_member_id on run.launch. The task is optional in
interactive mode - a taskless launch drops the member into the agent's TUI
with no seeded prompt - and required in headless, which has no interactive
surface, so the form disables Launch and says why rather than sending a
request the gateway will refuse (runLaunch in internal/sshd/handlers.go is
the same rule). Only what was actually chosen goes on the wire: an empty task
and the default tui mode are the server's own defaults. The Agent field
is always there. It reads "Choose an agent" until one is picked, and there is
no way back to that state once one is. Under it are the installed entries from
agent.list, then custom, the escape hatch that agent.list never returns
and that only launches where the deployment pinned a harness with
--harness-definitions. agent.list reports installation from the
selected account's persistent ~/.local/bin; uninstalled shipped entries
remain visible on the Agents page so setup can install them. The launch form
also remembers the most recently used installed agent for each account and
falls back to the first installed entry. With nothing installed nothing is
preselected, so Launch stays disabled until the member picks one: "No agent is
installed in this account." and a Set up an agent button sit beside the
field rather than replacing it, and the button opens the Agents view. A failed
list request shows its error and no setup button - nothing here can fix a
gateway that did not answer - and Launch stays disabled there too. Refresh
agents retries discovery after a connection failure or an installation
completed in another terminal.
Neither launch form asks which workspace to launch into: both take
activeWorkspace and say where the run will land, naming the workspace and its
base branch. The switcher is the picker, so a second one inside the dialog
would be a place for the two to disagree.
Launching is gated on run.launch and on the launch permission
(canLaunch). The local gateway advertises every method regardless of who is
behind it, so capability alone would put the button in front of someone the
server would refuse.
Base capture is server-side and precedes row creation. A mirror or local-base failure is reported by Launch and leaves no run row; the dashboard does not run a client-side base refresh before trying again.
Keyboard and focus
Everything the dashboard can do is reachable without a mouse, and every control a keyboard reaches draws the same focus indicator.
One focus indicator. focusRing in src/lib/utils.ts is the shared
outline utility. Every focusable primitive composes it, including
SelectTrigger, SelectItem, Checkbox and CollapsibleTrigger. Raw
buttons, links, menu items, dialog close controls and resize handles use it
too. Controls that fill a scroll container use the inset variant so the
outline is not clipped. Keyboard outlines appear immediately, without a
colour transition, and focus remains on a real control when a row or pane
changes. Verify focus, keyboard and accessibility as observable behavior, not
CSS class or source-string contracts.
It is an outline rather than a ring, for two reasons. Windows High Contrast
(forced-colors: active) discards box shadows, which is what Tailwind's
ring-* compiles to, and would leave the app with no focus indicator at all.
And ring-* already means "selected" on the member colour swatches, where a
focus ring in the same property could not be told apart from the selection.
The outline sits 2px outside the control, except where the control has no
room outside it: a row that fills its scroll container - a sidebar run, a run
list entry, a diff snapshot, a board card - and a menu item, which sits flush
against its neighbours. There it is drawn 2px inside instead. An outline
outside a full-bleed row is clipped at both edges by the scroller, and padding
the container would inset the dividers that are meant to run edge to edge. The
inset has to carry the same variant as the token
(focus-visible:-outline-offset-2): a bare -outline-offset-2 is one
pseudo-class less specific and loses at the moment the outline is drawn.
DropdownMenuContent and DialogContent suppress the outline on themselves:
each takes focus programmatically when it opens and has nothing to show for
it. Their contents are not the same case. A DropdownMenuItem takes real DOM
focus under Radix's roving tabindex, so it wears the outline like any other
control, keeping its focus: background as well. A SelectItem is that case
again: Radix moves DOM focus onto the highlighted option, so it wears the
outline inset like a menu item, and SelectContent suppresses its own for the
reason the other two containers do. A CommandItem suppresses the outline
too, and that one is deliberate: cmdk never moves focus to it at all, leaving
it on the input and tracking the highlighted row with aria-activedescendant,
so a background is all it has, and all it needs. It is the one row the focus
sweep is told to skip.
The shell's own keys, listed in the shortcuts dialog behind the ?
trigger in the status bar. ⌘K lives with the palette in
components/palette/index.tsx, Shift+/ with the dialog in
components/shortcuts/index.tsx, and the rest in
components/shell/nav-shortcuts.ts:
| Key | What it does |
|---|---|
⌘K / Ctrl+K |
Open the command palette |
⌘Shift+P / Ctrl+Shift+P |
Open the command palette |
n |
Launch a run |
g then b |
Go to the board |
g then l |
Go to all runs |
Esc |
Leave a run for the board |
n is offered, on both surfaces, only to a member who may launch. The
single-key ones carry no modifier, so keyboardBusy in src/lib/keys.ts
stands them down whenever something else has the keyboard: a text field or a
select, a terminal, an open menu or list box, or an open dialog. A stray n
typed at an agent has to reach the agent, n in a menu is that menu's own
typeahead, and n on a select jumps to the option that starts with it - the
guard finds a select by its combobox role, since the control is a button.
The g prefix waits 1.5s for the key that completes it, and any key that goes
somewhere else ends the wait.
The modified palette shortcuts are the exception, and have to be: they cannot
be mistaken for typing, and with the terminal holding the focus and swallowing
Tab they are the way out of a run. They stand down for a modal rather than for
anything that has the keyboard, through inModal and the store flag that names
the form the shell is hosting. They take the key from the browser either way,
so a stand-down cannot land the reader in the address bar.
That guard reads the event target rather than the document. Radix dismisses an
overlay from a capturing document listener without stopping the event, and
React commits the close in a microtask that runs before a window listener is
reached: asking the DOM what is open would find the dialog already gone and
let Escape both close the dialog and leave the run. The shell also stands down
on defaultPrevented, which is how Radix marks the Escape it just acted on.
The document is asked in one case only, by both guards: a key that landed on
body, where focus falls when an overlay removes the control that was holding
it, or merely disables it - Radix's focus scope watches for removals and does
not take the keyboard back from a button that disabled itself mid-flight.
There is no target left to read, so the fallback asks whether a dialog or a
menu is open, and a dialog playing its exit animation does not count. It does
not ask about terminals: focus on body with a terminal on screen is
ordinary, and the run dock hands the keyboard to whichever body replaces its
terminal - the refusal, the "Open shell" button, the unavailable notice -
rather than orphaning it.
An open tooltip is the one overlay that neither guard names, and it does not
need to: a tooltip owns no keys, and its trigger is an ordinary control.
Escape is the exception. React Aria dismisses a tooltip from a capturing
document listener that stops the event rather than marking it, so the shell
never hears that press at all: on a run, the first Escape closes the tooltip
and the second leaves. Every other key reaches the shell as usual, and a
tooltip closes on the first of them whatever it is, so a pending g is
untouched.
The status bar's details popup is the other overlay outside Radix, and it
dismisses itself, so it has to do by hand what Radix does for a dialog: its
Escape listener captures, and marks the key handled. The shell's own Escape
is a window listener registered when the workbench mounted, long before the
popup opened, so in the bubble phase it would run first and leave the run.
Capturing is what makes Escape dismiss the topmost thing and only that; an
open dialog still wins, through the same inModal target guard.
Blocking a control with aria-disabled rather than disabled keeps it in the
tab order, which is the point; the Styleguide rule below says why. The run
action bar, its overflow trigger, the terminal toolbar and the diff snapshot
list all keep their tab stops while their verbs are unavailable, and each
guards its own handler rather than relying on the browser.
The modifier is named after the reader's keyboard. The palette shortcuts
and terminal zoom keys accept Ctrl and Meta alike, because one keyboard sends
one and the other sends the other; only the printed label has to pick a side,
and shortcutLabel in src/lib/platform.ts picks it from the platform. The
palette badges read ⌘K and ⌘Shift+P on macOS, or Ctrl+K and
Ctrl+Shift+P elsewhere. Terminal copy, paste and find are Ctrl on every
platform, because that is what xterm binds; see terminal.md.
Tab strips behave as tab lists. The run-detail strip (tabs.tsx) and both
docks (components/dock.tsx) carry role="tablist", aria-selected, a
single tab stop that follows focus, and Left/Right/Home/End through
onTabListKeyDown in src/lib/keys.ts. A removable dock tab advertises
unmodified Delete and Backspace through aria-keyshortcuts.
Neither strip is a Radix Tabs, though the library is already a dependency.
The run strip cannot be: its four tabs are separate registry routes with no
common parent to hold a Tabs.Root, and Radix would emit aria-controls
pointing at panels that are not in the tree. The dock keeps each tab's close
affordance inside its native tab button instead of nesting another button
inside the tab list; pointer closing stops that tab's activation. What is left
of the pattern either way is onTabListKeyDown, one function both strips share.
Those keys move focus and nothing else. Selection does not follow focus here,
which the ARIA tab list pattern reserves for panels that are cheap to swap:
behind these tabs are a websocket attach, a patch fetch and an xterm host that
replays a transcript, so arrowing from Overview to Events must not open the tab
it lands on. Enter or Space opens the focused tab, a click opens the tab it
landed on, and both work because every tab is a real <button>.
Delete or Backspace closes the focused removable dock tab. A close repairs
focus to the next surviving tab, the previous one when closing the last tab, or
the Add terminal tab when the dock becomes empty.
Each run route names the body under the strip as its tabpanel, through
runTabPanel in tabs.tsx, and in both strips the selected tab is the only
one carrying aria-controls: on the run strip only one of the four routes is
active at a time, so the other three would be naming a panel that is not active
in the tree. An open dock's body is the panel its selected tab names; a shut
dock has no body, and a dock
holding no tabs is no tab list at all, so neither names anything.
Opening a run tab swaps the active center view. The previously active strip
unmounts; a cached primary terminal retains only its terminal surface, hidden
and inert, so the strip the next route draws takes the focus back. Otherwise
every tab press would drop a keyboard reader on body. It is armed
from the activation rather than the key press, and only from one the keyboard
produced, which carries no click count: a press that is cancelled arms
nothing, and a pointer click leaves focus where the pointer put it. The dock's
strip needs none of this, because it is not unmounted by its own tabs.
Resize handles are window splitters. The sidebar's and the docks'
separator handles take Tab, name the pane they size with aria-controls,
report aria-valuenow against their available bounds, move 16px per arrow
press, snap to those bounds on Home and End, and collapse the pane on Enter,
handing focus to the control that restores it. Pointer dragging stays within
the available space and follows the same bounds. Both handles set
touch-action: none, without which the browser claims the drag as a pan and
cancels the pointer stream the drag listens to. The sidebar's is a drag a
finger makes: it grows to a 24px hit area centred on its edge under
coarse:, without changing what it paints, and carries a z-index so the
half of it that overhangs the pane beside it is not covered by that pane.
The docks' is not - dragging a horizontal edge to size a terminal on a phone
is not something a finger does well - so that handle is not drawn on a
coarse pointer at all and the dock offers collapsed, half and full instead
(see Terminal view).
Terminal view
src/routes/terminal/ is the run-detail Terminal tab: xterm.js over
/ws/attach/<run> (docs/local-gateway.md). The run-detail routes share one
tab strip (tabs.tsx), so Overview, Terminal, Diff and Events are registry
routes on the same runId; the strip is a real tab list, arrow keys included
(see Keyboard and focus).
Every way into a run navigates to terminal, because that is where the agent
is: board card, sidebar row, run list, palette, feed entry, approval,
conflict chip, template, and the launch and onboarding forms. Overview stays a
tab for the metadata a finished run is read for, which src/routes/run.tsx
renders as one list: the reason, owner, agent account where the run borrowed
one, the created and changed times, and the last commit. The shared
RunHeader puts run state, harness, mode and branch below the title in its
first section, leaving the second section to the tab strip and actions.
Each active run-detail route renders one RunHeader; a parked terminal keeps
only its primary pane and therefore no header/tab IDs or action portals. This
lets the run's own state travel with the reader, and isRunRoute in tabs.tsx
is what keeps a sidebar
row lit while they move between the tabs. RunHeader keeps long task text
behind a View full task disclosure, so its compact summary never discards
the task.
Run Room and control lease
RunRoom is the collaboration view for the current run. It is mounted beside
TerminalView as a collapsed vertical tab; opening it loads the durable room
timeline and the current room status. The tab count is derived from the
collaboration slice's unanswered questions and queued steers.
The server-side lease, reconnect, takeover, steering, protection, attachment, and question contract is defined once in Run control and the Run Room. Dashboard changes must preserve that contract rather than restating it here.
The dashboard-specific state wiring is:
RunRoomreadsroomMessages, pagination, loading and action errors, androomStatusfromsrc/store/collaboration.ts; it hydrates history and status throughrun.room.listandrun.room.status, then merges live room events through the normal store sync.- The composer calls
run.room.postfor comments, questions, replies, and steer requests. Approval and denial callrun.room.decidewith the control metadata supplied by the terminal attach. TerminalViewowns the attach callbacks and passes the currentControlMetadatatoRunRoom.RunDockkeeps shell control state beside the agent terminal and exposes its own control action without duplicating room state.
On desktop the open room is a right-side panel capped at 420px. On a phone it is a full-viewport sheet; the terminal and room remain separate surfaces while sharing the same server state. Questions and queued steers therefore appear in the existing run-level count and Needs you grouping instead of creating a second dashboard inbox.
Candidate review in Run evidence
The existing EvidenceDrawer is also the candidate review surface; candidate
state is not a second run board. Its retained packet view keeps the heading
Recorded observations, not verification, and the candidate panel labels raw
packet snapshots as Raw packet — observation, not verification. Review
candidates opens the candidate list for the current workspace, and
Prepare candidate starts a review from ordered retained packets. Add
selected packet adds another exact source; the preparation action remains
Prepare candidate. Refresh candidates re-reads the list and
Show candidate loads the selected aggregate.
The review shows the exact ordered inputs, their observation snapshots, target and expected revision, candidate revision/state, conflicts and file resolutions. Apply resolutions submits explicit path edits or deletes and continues isolated assembly. Once frozen, the panel shows the exact argv, observed image, runtime identity and working directory, bounded resource and timeout details, setup/environment provenance, result, bounded output and provenance for each verification. Run verification starts the server-side check; Request delivery binds the selected verification IDs, target, expected revision, and action; Approve delivery or Deny delivery records the human decision; and Deliver approved executes the already-approved exact request.
Candidate mutations are disabled while the connection is Offline, Reconnecting, or Connecting. A return to Live refetches candidates and the selected review before enabling controls, so stale revisions and requests are not reused. A refreshed candidate with a new identity or version invalidates resolution drafts; an own partial Apply resolutions response keeps only untouched drafts that still conflict at the same assembly step. The panel preserves the gateway's real error rather than manufacturing a client-side result. Candidate review has no separate attention board, mission-progress surface, or agent decision path; it remains an evidence-linked human review flow.
The wire methods and bounded records are documented in integration.md; this guide records only the dashboard surface and its reconnect behavior.
A run id none of the four tabs can find renders one shared MissingRun
(src/components/missing-run.tsx) instead of that header, its tab strip and
four copies of a sentence with nothing to press. What it says is what the
store actually knows. While the server is unreachable it reports that, in
the same words run-list.tsx uses and with the same split - a dead token is
not a server that is retrying, so that one says what the error recorded -
rather than a claim about the run. While the store is still hydrating it is
a skeleton, held behind useDelayed so a fast load never flashes one. Only
once the store has hydrated does it say the run is not on the server, and
offer Back to board. The launch paths seed the run they just started -
both template launches and the onboarding first-run form, the way
launch-dialog.tsx does - so a launch never lands on the deleted claim.
CenterView retains visited TerminalView subtrees in its bounded cache while
the run route changes. A first visit creates the terminal only for a known run;
if that run has ended with no recorded terminal, the attach is refused and the
cached pane is never allowed to show the previous run's output under the new
run's name.
Primary run terminals are cached, not destroyed by navigation. CenterView
keeps up to four recently visited primary run terminals mounted in browser
memory. Cache keys include the authenticated identity and the terminal data
generation epoch, so a key is never reused across either boundary. The cache
contains only runs the member has visited: it does not prefetch runs and is not
persisted across reloads or browser tabs.
The normal run xterm requests up to 5,000 scrollback rows. Once the server
acknowledges geometry, xterm adapts the combined normal and alternate buffers
to stay near 1,000,000 cells; wider terminals therefore retain fewer rows.
CenterView trims inactive entries by their reported cell weights, with at
most four surfaces and its inactive-cell guard. This is a bounded cache
policy, not a fixed per-terminal or global scrollback promise.
Those limits apply to both warm cached surfaces and compact bootstraps. Either may omit older rows that remain available in the retained raw archive; the dashboard scrollback is not a full-history guarantee.
When a cached primary becomes inactive because the route changes, its
WebSocket is intentionally closed. That detach removes its Watching presence,
active control transport, and geometry participation, while retaining the
parsed current screen and bounded scrollback in browser memory. Reactivation
renders that warm surface immediately and reconnects with resume only after
pending xterm writes settle. The attach ack's server-issued resume_id fences
the settled cursor to one PTY incarnation. A valid same-incarnation resume
supplies only the bounded gap; an invalid cursor, ring, geometry, or
incarnation falls back to a compact current-screen bootstrap through the
hidden serial transaction. A completed entry whose session ended stays parked
and does not reconnect unless that same run is relaunched; then it performs a
fresh bootstrap once active.
Run deletion, an attach's final refusal, and an identity or data-generation change invalidate entries; LRU eviction disposes inactive entries. An invalidated active entry remains only long enough to show its refusal, then is disposed when it becomes inactive. Generation changes dispose the old generation immediately.
The Terminal view is a vertical split. Only the active terminal renders its
RunHeader, terminal tabs/actions, and RunDock/RunRoom; inactive cached
entries retain only the primary TerminalPane/xterm. This keeps auxiliary fixed
IDs and portals unique and restores focus handoff when the entry becomes active.
The agent terminal keeps flexible space above a RunDock below it. The first
header section contains the title and metadata on two lines; the next contains
tabs and actions on one line. The terminal strip left-aligns connection and
steering immediately after the search, font, clipboard and image tools, without
a spacer between the groups. Real gateway errors wrap there rather than being
truncated.
The dock header uses a min-h-9 strip rather than a fixed 40px height. It can
wrap actions below the tabs on narrow screens, while the tab list scrolls
horizontally. Add and collapse controls stay keyboard and pointer reachable;
the close affordance is pointer reachable inside each removable tab, and its
Delete/Backspace shortcut is available while that tab has focus. The splitter
is reachable too. The shell tab strip is a custom manual tab list with one
keyboard stop and overflow scrolling; it does not use a component-level tab
primitive.
TerminalPane keeps xterm's host geometry intact while layering the shared
toolbar and Find over it. During a dashboard run's compact current-screen
bootstrap it receives replaying={replaying}: the xterm host is hidden with
CSS visibility while each frame-sized operation is parsed through one serial
xterm write chain. The pane says Restoring terminal history throughout the
parse, paint delay, and saved-viewport restoration; the status clears only when
the surface is ready to reveal. It describes compact bootstrap restoration,
not a request for the separately retained raw archive. The run terminal,
run-shell tabs, and environment dock all use this shared replay gate. User
input and terminal-generated replies remain muted through the final replay write
callback. That callback opens input; a full replay remains hidden for two
paint turns, then its queued viewport restoration settles before visibility
is restored.
xterm is the sole vertical scroll owner. The host and every ancestor terminal pane suppress vertical overflow rather than creating a competing browser scroller. Native xterm behavior follows output while its viewport is at the bottom and keeps a user-scrolled viewport pinned above the bottom. Before a full structural replay or a column resize that can reflow rows, the controller captures that follow state or the pinned bottom offset. It restores the intent only for the same operation, with no intervening wheel, touch, scrollbar, or scroll-key interaction, and only if the active normal/alternate buffer still matches. A stale restoration never overrides a user's newer scroll position.
TerminalTools in the same module owns the search, zoom/reset, copy,
copy-last-screen, paste, and TerminalImageAction controls; the run terminal
supplies its connection and steering controls immediately after those tools.
Terminal tools delegate key behavior to the xterm controller and clipboard
helpers rather than putting those actions in each dock. useTerminalImage
owns the hidden file input, preview dialog, validation, upload call, and
shell-quoted path insertion. Its identity (terminal, target, active-tab
key, and enabled state) plus a generation token rejects a chooser, paste, or
upload callback that completes after the host or target has changed. The
find overlay sizes to the available width, so a narrow pane clips neither
its input nor its close control.
The image half of clipboard handling is registered by useTerminalImage with
registerClipboardImages on the current xterm input in capture phase. The
effect cleans up and registers again when the terminal identity changes; it
claims only image-file paste events and leaves text to the native xterm path.
clipboardKeys handles copy and deliberately leaves native paste alive, while
xterm-host.tsx composes it with zoom and find in xterm's one custom key
handler.
Shared terminal geometry. Every terminal renders the server's acknowledged
grid and subsequent geometry frames, including desktop writers whose panes
are larger than another writer's. useXterm.geometry() returns the local pane
measurement from FitAddon without resizing the renderer; setGeometry() queues
the server resize and optional attach reset between xterm writes. A font or
pane resize reports the local measurement, never the smaller rendered grid,
so a viewer cannot accidentally pin the shared minimum after it grows.
Queued geometry work is discarded when its terminal is disposed.
The server queues geometry with terminal output. SSH and in-process dashboard
attachments request ordered framing, and the gateway emits the geometry frame
before the next output record; independent geometry and output pumps would
allow a repaint to reach xterm at the old size.
The terminal on a phone. The PTY is the per-dimension minimum over the
clients that impose a geometry on it, so a client that fits xterm to its own
pane reflows the agent's screen for everyone else. The protocol's answer is
the follow flag (docs/local-gateway.md): a follower renders the session
at the size it already is, is left out of that minimum whether or not it can
write, and is told the size in the ack and in a geometry frame after every
change. On phoneScreen - a coarse pointer on a screen narrower than sm,
from src/lib/hooks.ts - all three terminals follow: the run's terminal, its
shell tabs and the environment dock, each of which is a session someone else
may be watching at a desktop's width.
A following terminal therefore:
- sends
followin the attach header.useXterm'sfollowoption suppresses local measurements and resize reports, whilesetGeometry()applies the server's grid just as on desktop. The header carriesstandardGeometry(80x24) only for a session being created, such as a new shell tab. The terminal pane exposes horizontal overflow only, so a grid wider than the phone can be panned sideways without creating an outer vertical scroller. While steering, the flag still keeps this viewer out of the shared size calculation. - does not steer on entry even on the member's own run.
Take controlis the only way in, anddisableStdinholds until the ack grants write - that is what makes xterm's textarea read-only, so a tap on a mirror raises no keyboard.
TerminalKeys is shown under the host on any coarse pointer while the
terminal is writable, a tablet included: Ctrl, Esc, Tab, the arrows, Enter
and Ctrl+C, each through terminal.input so the replay gate and
disableStdin treat a tap exactly like a keystroke. Ctrl is a one-shot
modifier held in the host (armCtrl), because a soft keyboard sends
characters and never a modifier: it rewrites the next character into its
control code, and a key it has no code for keeps the modifier armed rather
than spending it on the wrong byte. The two copy actions carry a visible
word beside them under coarse:, because a tooltip is the only other thing
telling them apart and hover is what opens one.
The phone's outer pane is a horizontal pan area only. Vertical wheel and touch gestures remain xterm scrollback operations; neither the pane nor its ancestors compete for vertical scrolling.
The dock has a persisted height
(UiSlice.runDockHeight, default 240px), a collapse toggle, and, once
expanded, a resizer on a fine pointer. A finger cannot drag an edge, so under
coarse the separator is not rendered at all and an expanded dock gets a
second header control instead, toggling between half of the room it has and
all of it; collapsed, half and full are the three states touch has. Half is
measured from the dock's own maximum rather than stored, so it follows the
screen. Both docks start collapsed
(initialRunShellDock, initialEnvTerminal), so the terminal a member came
for owns the window until they ask for a shell. Neither flag is persisted, so a
reload starts collapsed again, and the run dock's is per run because
shellDocks is keyed by run id. The header strip stays live while a dock is
shut, so its tab controls expand it: a tab whose dock is collapsed mounts no
xterm host and would never attach. Expanded-only dock actions are omitted while
the dock is collapsed. TerminalDock mounted with openOnMount expands itself
once, because the Agents and GitHub steps type into it.
Run-shell tab state and its socket registry live in src/store/terminal.ts;
the environment dock has the corresponding state and socket registry in
src/store/env-terminal.ts. The live attachment objects stay outside
persisted Zustand state, but a dock socket can outlive the component that last
displayed it. When a new xterm host adopts one, the dock calls
Attachment.rebind() with fresh callbacks. rebind(next) is a
host-replacement boundary: it cancels any old replay parser or drain with an
explicit cancellation signal, drops the old socket, updates the handlers, and
starts one fresh full replay for the shell or environment dock after
cancellation. Docks must not separately call reopen() after rebind(). Run
shell callbacks guard the current
{ runID, tab }; the environment dock guards its current tab. Host
subscriptions are removed on cleanup, while closing a tab or an exited shell
unregisters its socket. Thus route changes and tab remounts cannot deliver late
output, resizes, or image actions to a disposed host; only the selected shell
tab mounts an xterm host and transcript replay restores its content.
The primary agent attach follows the cache lifecycle above: route changes
suspend it rather than dispose its parsed terminal; eviction, invalidation, or
unmount closes it permanently.
The board's TerminalDock exposes Save environment while the member's
terminal is running. Stopping the container and discarding the saved image
are different decisions, so they are separate actions with separate
confirmations: Stop environment is the primary action in the dock and
confirms with a plain primary button, saying the saved image is kept, while
Reset to standard appears only when there is a saved_image to throw
away, is an outline action, and confirms with the image's own name and
carries the only destructive button either dialog has. That confirmation
names the container only while there is one to stop, because Reset outlives
it. Stopping therefore carries the rest of the status forward rather than
replacing it, so the image survives the container in what the dock knows as
well as on the server, and Reset stays on offer with the environment stopped.
When the terminal is running and saved_image is empty, it shows the hint
Installs here reach agents after you save. From the moment a tab opens until
its attach is acked, a spinner covers the terminal. Once a dashboard run ack
declares a compact bootstrap, the xterm host remains hidden with CSS visibility
and the pane says Restoring terminal history while each frame-sized
operation is parsed serially as it arrives. Input and terminal-generated
replies open only after the final replay write settles; the host remains hidden
through the paint delay and viewport restoration described above. Run-shell
tabs and the environment dock use the same settled-surface gate for their own
stream mode. A zero-length replay has no bootstrap bytes or paint delay; any
saved-viewport restoration still settles before the host is revealed.
The status words follow what
the dock knows: a terminal it has not seen running is Starting your
environment container, which is the wait Docker's container start accounts
for; a second tab, a tab switch or an expanded dock is Connecting to your
environment, with no container to start. A refused or failed start replaces
the terminal with the gateway's own error instead.
The socket is
attach.ts, framework-free. It reusesbackoff()fromsrc/lib/stream.ts, splits paste input below the gateway's 64 KiB frame cap, and keeps callbacks bound to the current terminal host. The primary run header requestsscreen:trueandinteractive:true; shells and CLI attachments keep their existing stream modes.screen:truebootstraps the compact current screen and bounded scrollback. The retained raw archive is downloaded separately through the authenticatedGET /api/runs/<run>/terminal-historyaction.Controller lease and compact bootstrap. A desktop owner's first attach asks for write; the server grants it only when no controller exists. Other members enter as mirrors, and a second tab cannot become a second writer. Until a member has taken control once, a live run opened as a mirror says Read-only mirror. Take control to type into the agent. A run that is starting shows its spinner instead; a run that is not steerable says This run is not running. Whether a member may steer is the server's answer:
-32001downgrades the attach to a mirror and disables the toggle. An occupied write request is a conflict and needs the Run Room's confirmed takeover.The attach ack's
replaycount is the exact bootstrap/live byte boundary, even when a WebSocket frame straddles it. For a fresh, live, or finished dashboard run attach, those bytes are the compact current-screen snapshot, not the complete retained transcript. A valid same-incarnationresume/cursor/resume_idsupplies only the bounded gap and keeps the warm screen. If the cursor or ring cannot serve it,resumed:falseselects a compact snapshot fallback.connectAttachparses frame-sized operations through one serial xterm write chain. Input and terminal-generated replies remain muted through the final write callback; then input opens while the host stays hidden through two animation frames and structural viewport restoration. It never allocates a transcript-sized browser buffer.Control changes stay on this WebSocket. The client sends
{"type":"control","request_id":17,"write":true,"takeover":true, "control_generation":8}(omittakeoverunless explicitly displacing a controller). The ordered response hastype:"control", the samerequest_id,ok, optionalcode/error, and authoritativehas_control,control_session_id, andcontrol_generation. An unsolicited lease or Steer revocation has norequest_id; it carriesok:false,has_control:false, and the exact revoked generation. An interactive attach remains open as a read-only mirror, with no replay or reconnect. Input carries the currentcontrol_generation; stale input is fenced. Take and release do not reconnect or replay, and the UI changes its writable state only from acknowledged metadata.A missing session is not a dead terminal.
-32004means the run has no PTY session, andinternal/sshd/attach.gorefuses rather than waits for one, so the client is what has to tell a container that is still starting apart from a terminal that is gone. Aqueuedorprovisioningrun is not attached to at all: the tab clears the pane and covers it withTerminalSpinner, the overlay the environment dock uses, reading Starting the run's container, and reports no connection state while nothing is connecting. The attach effect already re-runs onrun.status, so the run turningrunningattaches on its own. On a run that is up, a-32004is retried a bounded number of times on the usualbackoff()before it counts as final, because the server names that case transient - recovery starts a session under a row that already readsrunning- and says the client's retry is what resolves it. Only then does the tab show the gateway's error.A dead end gets no Retry. Retry is offered on every refusal except on a run that has finished, where no session will ever answer it.
endedStatusesis that set, and it mirrorsreplayableStatusin internal/sshd/attach.go, which isdomain.RunStatus.Terminal(): the same answer decides whether a missing session is worth waiting out, so the client cannot drift from the server gate. A run waiting for a human is not finished, so it keeps both its retry and the gateway's own words. A run that never started has no transcript either, and itsrun.reasoncarries the provisioning failure, so the tab shows that reason rather than sending the reader to the Overview tab for it. That substitution is keyed on the refusal's own code: only a missing session says anything about the run, so a revoked token or a withdrawn membership still shows the gateway's own message.Run-shell tabs request the controller lease. The
+control opens namest1,t2,t3, andt4; four is the per-run limit, six is the environment dock's. Each shell attach requires Steer and asks for the same one controller lease, so an occupied shell request is refused rather than becoming another writer. Each uses/ws/attach/<run>?shell=<tab>and closes its socket when the tab is closed.RunDockexposes an uploaded-image path only while its attached identity still matches the current{ runID, tab }. A-32001response does not reconnect; the dock replaces the terminal with You can view this run but not open a shell in it. A normal1000socket close removes the finished tab.Every attach answers for itself. The agent run slice is reset when the view mounts, and a successful attach clears the standing refusal. Otherwise a denial outlives the socket that produced it: leaving the tab and coming back would show a live terminal beside a stale error, with steering greyed out even after
run.handoffgranted it.Interactive revocation stays on the mirror socket. The server re-checks a live attach's authorization every few seconds. Losing Steer sends an unsolicited control notification on the existing interactive WebSocket; the client applies its exact revoked generation, disables input, and remains a read-only mirror without replaying or reconnecting. A raw legacy (non-interactive) attach retains the named 1008 close, reason
steer permission withdrawn. Membership withdrawal also uses 1008, reasonmembership withdrawn, and stops reconnecting. A refusal frame's own close is handled only when no prior control response explains it.Find, zoom, and clipboard share xterm's key handler.
xterm-host.tsxchains zoom, find, andclipboardKeysin that order; the first to claim a key stops it reaching the shell.clipboardKeysclaims copy shortcuts but leaves native paste alive.useTerminalImageseparately registers the capture-phase image listener described above, so an image event is claimed only when the current terminal has a live image handler and plain text never takes that path.Ctrl+Shift+Fopens the find barTerminalPane(src/components/terminal-pane.tsx) draws over the terminal, backed by@xterm/addon-search; it reports No matches from the addon's own answer rather than tracking a count.Ctrl+=,Ctrl+-andCtrl+0moveUiSlice.terminalFontSize, clamped to 8-32px byclampTerminalFontSize- on the way in from a keystroke and again in the store'smerge, because a same-version reload never reachesmigrateand xterm does not validatefontSize. The size is one persisted preference behind every terminal, applied to the live instance and re-fitted rather than by rebuilding it, which would throw the scrollback away.DOM renderer, deliberately.
@xterm/addon-webgl0.19.0 can reuse stale glyph-atlas positions under heavy glyph churn (xtermjs/xterm.js#6038), garbling scrolled rows until a forced refresh; the DOM renderer never desyncs. The terminals render in the shipped JetBrainsMono Nerd Font Mono (src/lib/term-font.ts, declared insrc/index.css), so agent TUIs get their powerline and devicon glyphs at the same advance as text. The terminal opens only once regular and bold faces are loaded, because xterm caches glyph metrics synchronously atopenand would otherwise bake fallback metrics in.Delivered steers need no extra terminal work. Once a Run Room steer request is delivered, the server writes the attributed member-coloured banner into the PTY stream itself, so it arrives as ANSI and xterm renders it like any other output.
Board cards get no live terminal previews in v1 (spec cut-line).
The terminal's colours are the one place the tokens cannot be used directly:
xterm needs resolved theme values rather than the CSS variables, so the view
reads the computed background and foreground off its own host element and
re-reads them when the dark class on <html> changes.
Run events tab
src/routes/terminal/events.tsx is the run-detail Events tab: the workspace
activity feed pinned to the run in view. It drives the same feed slice and
paging readers the team activity view uses (openFeed, drain,
olderFeed), and both views render rows through the one shared component
(src/components/feed-entry.tsx), whose describe covers every feed payload -
run.agent and run.diff included - so the two feeds cannot drift apart.
Because the slice is shared, the pin is borrowed: the tab captures the
filters on mount and restores them on unmount, so the team Activity view
opens with whatever it had chosen.
Diff timeline and conflict chips
src/routes/diff/ is the run-detail Diff tab, and src/store/diff.ts holds
both what it renders and the overlap set the conflict chips read.
- The patch is fetched, the events only say when.
run.diffcarries per-file stats and no patch text, so a snapshot bumps the run'srevisionand the tab re-fetches the current diff fromGET /api/v1/run/<id>/patch(docs/local-gateway.md) whenever that has moved past thefetchedrevision the stored patch answers for. Counters rather than a stale flag, because a snapshot landing during a request would write true over true and then be cleared by the response: the answer records the revision it was issued at, and anything newer asks again instead of showing a diff that is behind while calling itself fresh. A failure records the revision too, so it cannot spin; the next snapshot or the Refresh button asks again. - Selecting a snapshot renders that interval. The chronological list is
the
run.diffevents themselves - time, file count, totals - and each one also carries the tree it wrote and the tree before it. Selecting a snapshot fetchesGET /api/v1/run/<id>/patch?from=<parent_tree>&to=<tree>, the diff between those two trees, which is what the run changed in that interval; clearing the selection goes back to the current diff against the fork point. An interval is addressed by two tree ids and so can never change: the tab keeps each one it has fetched and never asks again, and the revision counters above are only for the cumulative patch, which does move. The selection keys on the snapshot's timestamp, so a new snapshot prepending to the list never retargets it. A snapshot carrying no tree - one from a server that predates them - is not selectable. The list is capped at 40 per run and starts empty on every page load, because there is no history to replay. - The timeline yields to the patch on a narrow screen. Beside the patch
from
mdup it is a list. Below that it sits above the patch, where the header, the tabs, Land, the stats row and the local-review block are already between the member and the first line of code, so it becomes a disclosure that starts closed. With no snapshots the stacked layout drops it entirely and the grid is one column, because two lines saying the list is empty are two lines of the phone's screen; beside the patch it stays, and says "Nothing since you opened the dashboard." - there it is the only thing that explains why there are no intervals to pick. The list starts empty on every page load and fills as the run works, so empty is the normal state either way. - Colour is the whole of the highlighting.
parse.tssplits the unified diff into files, hunks and line kinds;patch-view.tsxpaints those kinds. The dashboard never edits code, so there is no editor and no language grammar - why neither Monaco nor CodeMirror is a dependency. The server sends complete run diffs up to 64 MiB, and the parser remains tolerant of incomplete input from a failed transport. - Long lines wrap or scroll, and the pointer picks which first. Wrapping
breaks the column alignment a diff is read by, and side-scrolling means
panning every file section separately - which a phone cannot do well. So
Wrap lines in the stats row is a toggle, starting on for a coarse
pointer and off for a mouse. The choice itself is a view preference on the
UI slice (
diffWrap), stored like the sidebar width, because only one run-detail route is active at a time and component state would forget it on every trip to the Terminal tab. The Files tab's diff pane reads the same preference: it has no toolbar to put a toggle in, so it never sets one, but a member who turned wrapping off on the Diff tab meant it for diffs and not for one tab of them. - The verbs are not here, the answers are. The tab keeps what is only
about reading the diff - the refresh, the snapshot list, the two copyable
gitcommands that review the run branch in the linked repository, and the output of the last pull (review-commands.tsx). Fetching the branch and closing the run are verbs, so they sit in the run action bar in the header with every other verb rather than a second time in the tab; the fetch output is an answer rather than a verb, so the pull records it on thelocalslice and the tab shows it where a member reviewing the branch will look. The whole block is gated on thepulllocal verb, the same one that fetches the run branch into the repository it explains: a gateway without it - a phone on the server's dashboard - has no repository for those commands to run in. Workspace Source control is separate from this run-branch pull. - Conflict chips write their list out for a finger. The overlapping file names live in the chip's hover tooltip, which a touch screen has no way to open, so on a coarse pointer the same list is rendered as visible text beside the chip. A tooltip is a hint for a pointer, never the only copy of a fact.
- Conflict chips are advisory.
conflict-chips.tsxregisters intocard:chipsand the Diff tab renders the same component in its header. It reads the overlap set the conflict radar reports (run.overlapsat hydration, thenrun.overlapevents), names the file and the other member, and navigates to their run. The event payload names peer runs but not their owners, so attribution comes from the runs the store already holds; an empty peer list means the overlap cleared and the chip goes.
Team surfaces
src/routes/team/ is presence, the shared approval inbox, the workspace
activity feed and budgets - the four readouts of the team features
(internal/approvals, internal/timeline, internal/cost). None of them owns
a view of its own in the shell: they reach the run card and the status bar
through the slots those surfaces expose, and the two full views are registry
routes (approvals, timeline), reached from the sidebar nav and the palette
like every other view, and gated on the same method the nav gates them on.
The approval inbox is for agent permission and plan approvals. Run Room questions and queued steer requests stay contextual to their run. Questions contribute to the existing Needs you projection, both contribute to the Run Room tab count, and neither creates a second action inbox.
- They refresh from the event cursor, not a timer. Every event the store
applies advances
lastSeq, and that is the only signal available that a teammate may have changed one of these reads - the gateway has no push channel for a roster or a queue.useTeamRefreshinsrc/routes/team/sync.tsre-reads them when the cursor moves, with a floor between refreshes so a chatty run does not become a request per event. It is mounted from the status-bar contribution, the one surface that is always on screen, which is also where the presence heartbeat lives. It also refreshes and beats ononWake, because a backgrounded tab freezes both timers: a phone returns with its presence already expired server-side (the TTL is 45s) and with no cursor movement to show an approval that arrived while it was away. The refresh keeps the same floor as the debounced one, so app switching cannot turn into a request per workspace each time; the heartbeat is one request and always goes. - One refresh covers every workspace, and there is only the one. These
reads are per workspace on the wire, and a workspace is a repo plus its
team settings. A deployment has a handful of them and they outlive every
run in them. So
refreshTeamreads all of them each time rather than splitting into a bounded recurring pass and a wide occasional one. Both readouts it feeds ask a whole-deployment question anyway - the status bar claims the worst budget state anywhere, and the badge claims the size of the whole queue - and a workspace does not stop being over its cap or holding an undecided request when its last run finishes, so no subset could answer either one. Failures leave the last good data in place. - An unreadable queue says so.
refreshInboxkeeps the first per-workspaceapproval.listfailure, and the inbox, the nav entry and the status-bar chip report it rather than "Nothing is waiting on a decision.", which over a failed read means "no agent is blocked". Workspaces that answered are still listed. Each read is stamped, so a slow failure cannot overwrite a newer good answer. - The heartbeat is narrower. It claims only the workspace in view -
focusedWorkspaceprefers the route'sworkspaceId, then the workspace of the run in view, thenactiveWorkspace- because presence is keyed on (member, workspace) and beating them all would report you online to teammates in workspaces you have never opened. - The queue is every workspace's, in one list. The inbox view reads them
all again when it opens, because it is the surface that shows the requests
themselves rather than a count.
Each row names the workspace the request belongs to, since the list crosses
them.
Decisions go through
approval.decidewith the run the request belongs to, so the server attributes them and applies the steer check: a refusal is rendered as the server's answer, never predicted by the form. A request the user has just decided stays on screen reporting its outcome, laid over the fetched queue, because the next fetch no longer returns it. - The feed opens at the end of the log.
workspace.timelinepages forward from a cursor only, so the view first asks for a page past the end - that answer carries the log head - and opens a window back from it; the live tail is the same paging call from the cursor the window reached. "Load older" reads the new stretch only, up to where the previous window began, keeping what is already loaded: re-reading the whole widened window would spend the page budget on history the feed already has and lose the newest end of it. Pages merge into the feed in sequence order however they arrive, so the window stays oldest-first. When that budget does run out the view says so rather than stopping quietly. Every open stamps the read, so pages still arriving under the filters the user just left write nothing. Actor dots are the member's own colour from the member payload. This is the one scoped surface that keeps a workspace picker of its own, because comparing what happened in one workspace against another is the question the view exists to answer; it opens on the active workspace and switching it clears the run filter, since a run belongs to exactly one workspace. - A feed row names the event's type; it does not print it. The name comes
from
src/lib/events.ts, which is also where the type filter's options are named from, so an option and the rows it selects cannot call one type two different things. The wire string stays as the row's tooltip. The describer table insrc/components/feed-entry.tsxis keyed by the map's own type, andfilterTypesby those keys too, so a type cannot gain a name without a description, or the reverse, without failing the build. A type the map has never heard of renders as its wire string, because a server newer than the dashboard can emit one. - Activity run links preview labels to two lines, while the button retains the full label in its accessible name and
title. - Below
smthe filter bar folds. Four labelled selects are most of a phone screen before the first entry, so only the workspace one - the filter that scopes the feed at all - stays out, and Run, Member and Type go behind a Filters disclosure. Its caption counts the three that are set, so a feed narrowed by a filter the member cannot see is not read as an empty log. - A budget warns, it never stops anything. The status bar shows the spend
and the worst state any workspace is in (
ok,warn,exceeded) - every workspace, ones with nothing running included, which is what the wide read above is for - and says so in those words, naming each workspace and its cap in the tooltip. A spend that includes unmetered runs renders as a floor ($1.20+), because a harness with no adapter reports nothing. - Workspace controls live on the workspace view, not the status bar.
routes/workspace.tsxis one workspace: its name, its base branch, its runs, and buttons for the budget (budget.set), the steering policy (workspace.settings) and, for admins, Source control. Each is gated by its server-side permission. A spend ceiling and a steering policy belong beside the thing they govern rather than in a header two views away. The settings dialog shows the base branch without offering to change it: runs have already forked from it, so editing it there would only make the displayed branch disagree with the branches on disk. Source control reports local-only, pending, ready or failure, including the configured source, branch, accepted SHA and last check time. One-time Configure starts fromWorkspace.Originand offers public or deploy-key access; the latter exposes only a public key to copy and the GitHub deploy-key link. Verify/Refresh reports the server's result; Adopt candidate and Disable mirror require explicit confirmations. - Watcher avatars come from the roster's
watchingset, which the gateway fills from live PTY attaches - the browser's attaches included. - The same refresh reads
GET /api/v1/diskand writes it onto the storedserver.info, which is what fills the status bar's disk gauge.
Manage workspaces
src/routes/workspaces/ renders a flat, bordered list of workspaces. Each row
shows its name, creation time, base branch, steering policy and an Open
button.
Settings
src/routes/settings/ is a local-gateway surface. It shows the local link,
the sync daemon and Mirror run files to your repository in flat bordered
sections. That section starts and stops a live run's sync overlay. The
server-hosted dashboard omits these machine-local settings and controls.
Workspace base freshness and Source control live on the workspace page. A
configured mirror is refreshed there with Verify/Refresh; a local-only
workspace uses its local base. There is no recurring base-refresh button.
Onboarding wizard
src/routes/onboarding/ is the local-gateway guided first-run path, six
steps: Link, Git identity, Workspace, Repository, Agents, First run. It
renders only where the gateway serves the client-machine verbs (the capability
descriptor lists link.status); a gateway without them gets an explanatory
empty state instead of a broken wizard. That copy names the gateway and what it
cannot reach - an SSH identity, a repository on the member's own computer -
rather than guessing at the device in the member's hand, which the app has no
way to know. The server-hosted dashboard has no machine-local onboarding
wizard: it starts at the board, while shared runs, terminal, Files and
configuration surfaces remain available through the server gateway. Link,
Workspace and First run live in steps.tsx; Repository is repo-step.tsx, Git
identity is git-identity-step.tsx, and Agents is agents-step.tsx with its
GitHub part in github-connect.tsx and its configuration import in
profile-import.tsx.
Navigation is two levels: the step index, and one sub-screen name owned by
whichever step has sub-screens. The Agents step owns both of today's - a
harness's setup screen, named by the harness, and the GitHub connect screen,
named @github - and the wizard holds the name, so Back closes an open
sub-screen first and leaves the step only from the step's own screen. A step
with sub-screens takes them as setup and onSetup rather than keeping them
in its own state.
The wizard owns that Back button but passes it down as a back node, and each
step puts it in its own action row, beside Create workspace, Skip for now or
Launch. Those rows stick to the bottom of the wizard's scroller, so a step as
tall as Agents with the terminal dock open cannot push the way on and the way
back off screen. The exception is the Repository step before a clone is
connected, where Back sits in the path form beside Add remote; that screen
is one field long.
In the step header, every step the member has already reached carries a check and is a button that jumps to it, forwards as well as back, so walking backwards does not strand them on a step they cannot leave. The current step and the ones never reached are inert text.
The Git identity step collects the name and email the member's commits are
authored as, saved on the server with member.git. Where the gateway serves
git.identity it prefills them from this machine's own git config, without
overwriting a field the user has typed in; what the member already saved wins
over both. Skip moves on and leaves the server's fallback in place, so the
step never blocks the wizard. See teams.md for what the identity
does once it is set.
The Repository step asks for a clone on this machine, and the path must be
absolute - a leading /, a drive letter, or a UNC prefix, all three accepted
whatever the machine is, because the check only catches a plainly relative
path and a Windows dialog answers C:\.... The field always offers the
folders link.status already knows - the linked clone and every named
profile's - as a datalist. In the desktop shell it also gets a Choose
folder button, which opens the native directory dialog through
window.aetherDesktop.chooseFolder, writes the answer into the field and
clears any error the last attempt left; cancelling leaves both alone, a
dialog that fails puts the shell's own error under the form, and the whole
path form waits while a dialog is open, because a chooser that is not modal
to the window leaves it live and a late answer would land on top of whatever
was typed or submitted meanwhile. That wait is the step's own state, so
leaving the step and coming back is the way out of a chooser that died
without answering. Typing stays the fallback, because a browser tab has no
dialog and a shell built by an older aether gui build has no method.
The step adds the aether remote (link.repo) and then seeds the workspace:
where the local gateway serves repo.push it shows a Push now button.
The gateway compares the clone's base branch with the workspace's copy before
pushing and answers with one of four states, so the second member to join a
workspace reads what happened instead of git's ! [rejected] main -> main (fetch first).
Local-only workspaces retain Push now, Fast-forward my clone, daemon base pushes and direct base writes. A mirrored workspace owns its base on the server and rejects direct base writes; run branches can still be pulled. After repository linking, onboarding may link to the workspace's Source control, but it never silently authorizes a source.
When link.repo answers an origin, the connected line adds Runs push to <origin>: the upstream a run pushes to, the same one aether link --repo
prints. That push destination is separate from the Source control read source.
A link that recorded none says nothing about one.
pushed names the branch that landed; up-to-date names the commit the
workspace already has. Both keep git's output in a "What git did" panel,
open on arrival because Everything up-to-date and [new branch] are both
success and mean different things, and Continue moves on.
behind means the workspace is ahead. The step names both tips and offers
Fast-forward my clone, which runs repo.fast-forward and then reports
the new tip, whether that branch was the checked-out one, and whether the
working tree is dirty. The panel then shows both outputs in the order git
produced them: the comparison's fetch, then the fast-forward. diverged
means both sides moved on: the step names both tips and offers the fetch,
log, rebase and push commands to resolve it by hand, copyable as one block,
with no fast-forward button, because Aether never force-pushes.
Both states keep Push now, which re-compares - the thing to do after
resolving by hand - and both take away the copyable git push -u aether <branch>: it is the command that produced the rejection this comparison
exists to replace. That command stays only where it is the right one, which
is a clone the workspace has not moved past: before any push, after one that
failed, and after one that landed. A refusal keeps the user on the step with
git's own output in a monospace block. The three outcome panels - behind,
diverged and the fast-forward result - are aria-live="polite", because they
appear without a page change.
The branch is the workspace's base branch, so a workspace created with
--base seeds the branch its runs fork from. When repo.push is unavailable,
the step shows only the copyable command.
What the step settled - the clone path, the remote the gateway wrote,
git's push answer, and the fast-forward once one has run - lives on the UI
slice as onboardingRepo, not in the component. Each answer is written onto
the record as it stands in the store rather than the one captured at render,
so a push and a fast-forward started from the same screen cannot lose each
other's result. Each answer also carries the clone it ran against. A member
who re-points while a request is in flight has that answer - and its error -
dropped rather than merged onto the repository that replaced it, and the new
form starts with its own push button rather than the old request's spinner.
A member who picks another workspace keeps the answer on the record it was
issued for, and the step, which shows only the record matching the selected
workspace, never renders it against the workspace that replaced it.
Returning to the step shows that connected
repo with Use a different repository to go back to the form, prefilled
with the old path; a blank form there would ask again for a remote that
already exists.
The UI slice persists the resume point, the furthest step reached, the selected
workspace, the connected repository and the First run draft; finishing the
wizard or navigating away from it clears all five. The step is stored by name
rather than by position, so inserting a step - as "Git identity" was - never
relocates someone who is mid-wizard. The persisted state is versioned, and one
migration in web/src/store/index.ts covers every older shape: versions 0
through 2 stored the resume point as an index, so those numbers are read back
as the steps they named; versions 0 and 1 stored a Repository answer this build
cannot use - version 0's predates the comparison states and version 1's carries
no link id - so it is dropped rather than rehydrating a blank panel; and
no version before 4 has a furthest step, so the resume point becomes it, or
the first backward jump would turn every later step inert. Anything it cannot
place starts over. Repository is where the workspace first becomes
load-bearing, so resuming onto it or any later step without one falls back to
the workspace picker; the steps before it resume where they were.
Hydration reads link.status first for a local gateway: a linked machine is
marked onboarded before the redirect decision, so it never re-enters
onboarding after a fresh GUI launch. An unlinked local gateway still routes
here when onboarded is false. The server-hosted gateway has no local link
state and opens at the board. Completing the final step or navigating
elsewhere marks the UI onboarded and clears that wizard state.
The Link step distinguishes no configured server, a server with no repository,
and a fully linked server. It refreshes on Retry and when the window regains
focus, so a separate aether link command appears without restarting the GUI.
The Agents step has three optional parts and never blocks: Skip for now is reachable from every state, including an open setup shell and a failed configuration import.
Part A lists the setup-capable harnesses from env.harnesses against
agent.list, saying for each whether it is installed on this machine and
whether the server lists that name. The copy states what those two signals
actually mean - agent.list includes shipped harnesses even when the server
account has not installed them, so the list is not a "set up" badge - and Set
up embeds the same AgentWizard the Agents page uses, driven with the
harness and workspace already known so it opens the agent-setup shell
without a form. Setup confirmation checks agent.list for an installed
executable, then runs env.save - the call the dock's Save environment
button makes - before handing that agent to the First run step, which
preselects it. The save is the point: an executable that exists only in the
running container is not in the image runs start from. The done screen names
the saved image. A missing executable, a failed check or a failed save keeps
setup open for retry with the real error. The vendor login is not checkable
from here, and the copy says so.
Between the two, github-connect.tsx connects the member's GitHub account.
The closed <section aria-label="Connect GitHub"> says what a connection
buys - runs push branches and open pull requests as the member, and commits
are signed with a key kept in their environment home - and Connect GitHub
opens the sub-screen. The sub-screen mounts the same TerminalDock the
setup screen uses and calls github.probe once that dock reports a running
container: the probe runs gh --version inside it and refuses when there
is nothing to run it in. Until then the screen says it is waiting for that
terminal to start, and once it has one, that it is checking it for gh;
through both it shows no login command and types nothing. I've logged
in stays live there on purpose: github.connect makes the same check
itself, so it is the way out of a probe that never settles.
Once the probe answers with a gh that can do the login, initialLine
becomes typedLoginCommand from src/lib/github.ts: gh auth login --hostname github.com --git-protocol https --web --scopes admin:ssh_signing_key, prefixed with Ctrl-U. The member can have typed at
the prompt while the check was out, so the line clears it rather than
landing on top of it. Ctrl-U kills backward from the cursor, so anything to
its right survives and is appended to the login command; Ctrl-K would cover
that, but the terminal falls back to /bin/sh on an image without bash and
dash passes Ctrl-K through as input, which breaks the command. The byte is
raw input either way, so a member sitting in an editor or a pager gets it as
one. The screen echoes the command itself, without the prefix, in a code
block for anyone who would rather type it, and says what that login looks
like from inside a container: gh asks the member to press Enter to open a
browser and then reports that it could not open one, so the member presses
Enter, ignores the failure and opens the printed URL with the one-time code.
A probe reporting no gh, a gh that would not run, or one too old for the
login check replaces the login command and its explanation - not the dock,
which is where the remedy is carried out. In their place comes the screen's
own sentence about the gh the probe found, then the server's commands as
CopyableCommands - the admin one when there is one, then the member's -
and gh's own answer in the same monospace pane refusals use; no login
command is offered at all, and I've logged in is disabled, because
connecting would only collect the matching refusal.
There are three shapes. A gh the member installed into their own
environment home - which the probe reports as path, and which comes first
on PATH and survives every image - is named as the file it is, with no
image command at all. A member on their own saved image is
offered the install-and-save that keeps it first, and told that aether env reset removes it. A member on the server's standard image is told an admin
runs the command, or just to reopen the terminal when the image has already
moved without them. Every remedy the copy names is a button on the dock
right below: Save environment, Reset to standard, Stop
environment. Check again re-runs the probe, because none of those
three restarts the container by itself.
A probe that fails, or a dock that never got a terminal at all, puts the login command back - shown, not typed, and said to be untyped - because a member whose gh is fine must not be stopped by a check that could not run. A dock that has a terminal and merely refused a write is not that, and does not release it. A failed probe renders its own error above Check again; a dock that could not open reports in its own pane, and gets no button, because a second probe cannot run without a container and the attach is already retrying.
I've logged in calls github.connect, which does the non-interactive
rest on the server; success names the account and the signing key's
fingerprint, and Close returns to the step, which then
reads "Connected in this session as <login>" - the connection is React
state that a reload loses, said the way the agent rows say "Set up in this
session". A connection counts the way a set-up agent does for the step's
primary Continue. Server refusals - most often "not logged in to
github.com in the environment terminal" - render verbatim in the same
monospace pane the Repository step gives git's output, because gh's answer
runs to several lines, and leave the screen open to retry.
Where the gateway serves no terminal socket the screen gives the CLI path
and nothing else: aether terminal, the same gh auth login, then aether github connect. There is no I've logged in button there - with no
terminal to log in through, the whole flow is the CLI's. The login command
itself lives in src/lib/github.ts, so the screen and the Playwright spec
assert one string.
Configuration import is an explicit, one-time directory import in the
local onboarding flow. config.roots supplies destinations such as
~/.claude, together with the destination-specific runtime paths that can be
left out locally. The browser waits for that response: a known unique basename
selects its destination automatically, while an unknown or ambiguous basename
requires a destination choice before previewing or reading any file bytes.
The raw browser File handles stay local so changing the destination clears
the old preview and re-reads with the new policy; a generation guard prevents
a slower old read from replacing the current preview. This action is not
available from the server-hosted dashboard, because that browser cannot read a
machine-local directory; the server dashboard still exposes Files and
configuration editing through config.tree, config.read and config.write.
There is no local discovery scan or directory watcher; the picker is the only
onboarding import action.
Credential names found in any path component and *.pem files are always
left out in the browser. Runtime/history paths come from the selected
destination's runtime_ignores metadata and use exact, root-relative
case-sensitive component-prefix matching. Every other selected byte is
uploaded and server-scanned; the result reports accepted file/byte counts and
server exclusions. A response with error is an incomplete import: the UI
reports the committed counts, exact canonical imported_paths, and the real
error instead of showing success, and warns that copied files remain. If the
RPC fails without a response, the outcome is unknown (some files may have been
copied); inspect Files before retrying. There is no watcher or automatic
retry - choosing a directory and importing again is always explicit. The
import limits are 2,000 files, 1 MiB per file and 20 MiB decoded in aggregate,
with a 30 MiB HTTP request cap. Empty and binary regular files are preserved,
but browser imports send mode 0644 and cannot preserve executable mode or
symlinks.
Accepted files change the calling member's persistent home immediately, including for already-running agents that share that home; an agent may need to reload. Auth/vendor login is separate.
The First run step is the last one, and launches a run in the workspace the
Workspace step settled on. Its Agent select offers only the entries
agent.list reports as installed in this account - the launch form's rule
without its custom escape hatch - and the agent the Agents step just set up
is preselected when it is one of them. With none installed the step drops the
picker: it says a run launches an agent in a container and none is installed
yet, and offers Set up an agent, which jumps back to the Agents step.
Skipping setup and then picking a shipped name is how a member used to reach
the server's refusal only after the run had launched. A failed agent.list
shows the server's error with Retry as the primary action and no setup
button, because a gateway that could not answer is not the same fact as an
account with nothing installed.
The step's "No agent subscription yet?" note points at the CLI and stays on
screen once a workspace is chosen; "Prove the plumbing without an agent subscription" in
quickstart.md covers what fake is and how to launch it.
Update prompts
src/components/update-banner.tsx is where the dashboard says a binary is out
of date: it hosts the banners, with the CLI one in
src/components/cli-update-banner.tsx and the pieces they share in
src/components/update-banner-shared.tsx. It is mounted by AppShell above
everything else, because an out-of-date binary is about the whole app rather
than the view that happens to be open. The CLI and shell prompts read
update.check from the local gateway, because the dashboard can update only
the machine running aether gui. The server prompt asks the linked server
about itself and appears wherever the member is an admin.
- Two reads. The host reads
update.checkon mount, again every half hour, and again whenever the window comes back to the front, onfocusand onvisibilitychangealike. No read runs while the window is hidden, the timer included, so the freshness this buys is best-effort on a minimized app and immediate when the member returns to it. The desktop app runs for days, so a release that ships after launch has to arrive on its own, and the half-hour timer is half the gateway's cache period (docs/local-gateway.md) rather than all of it, which bounds the wait at the two added together. One reader serves both the re-checks and the Update button. A re-check never starts while a lookup is out, and every read is numbered so that only the newest one's answer is written: a read issued before the click is served the cache the click has yet to refresh, and would otherwise bring the superseded release back. The re-checks also stand down for the length of an install, so nothing renames the banner over the release being written to disk. A successful lookup is cached for an hour, so the re-checks cost the gateway one request to GitHub an hour at most; a failed one is cached for five minutes, so an unreachable GitHub is retried sooner. The answer goes on thelocalslice, which is also what the status bar reads. A re-check that fails is swallowed, as the first read always was: it leaves the last good answer standing rather than blanking a banner that is already on screen, and the next re-check still runs. It readsserver.update_statusas well - any member may - and re-reads it on every reconnect and wheneverserver.infonames a different version. The reconnect is the one that matters: a server that updates itself re-executes, so the socket drops and comes back, and that fresh status is what ends the banner and the notice.connect()re-hydrates on the same signal while an update is in flight, even with a cursor to replay from, because only a freshserver.infosays the server came back on the new version. A read that fails is recorded, not swallowed: the banner then says it could not read the status, with a Retry, rather than claiming the server cannot update itself. - The CLI banner is for everyone. It names the new version and the running
one, says what updating costs - it replaces the
aetherbinary on this machine and restarts the dashboard, taking attached terminals and any running sync session with it, while the runs keep going on the server - and offers Update now, the release notes, and a dismiss. What the button will do is decided before the click, fromupdate.check'sinstall_method(docs/local-gateway.md): direct offers the button and nothing more; admin-prompt (macOS with a GUI session and acli_pathin a directory only root can write, such as/usr/local/bin/aether) offers the button and says, before the click: macOS will ask for an administrator password: {cli_path} is in a directory this account cannot write to. The dialog is labelled osascript, the tool Aether asks through. Aether never sees your password.; manual (Linux with a directory this account cannot write, Windows, or a macOS gateway the dialog cannot serve - the rule is indocs/local-gateway.md) offers no button and shows the command to run instead -sudo aether updatewith a copy button, or the release link where the platform has no self-update at all. Clicking Update now first re-readsupdate.checkwithrefresh: true, which skips the gateway's cached answer, and puts that on the store, so the version the banner names while the download runs is the release that was newest a moment beforeupdate.applyresolved its own. A fresh answer saying this machine is already current - the update ran in a terminal while the app was open - installs nothing and says so, Aether vX is the newest release. Nothing was downloaded., rather than taking the banner away under the click. That notice is about the answer it was made from: when a later release lands, the offer and its button come back. A fresh check that cannot reach GitHub shows the gateway's message and leaves the button usable. Otherwise the call goes on toupdate.applyand the banner follows the phases below; nothing else reconnects, because the existingConnectionErrorpage already owns a gateway that goes away. The done state names what the gateway said is left to do, every binary the swap replaced and, on a single-box install whereaether-serverwas one of them, therestart_commandthe gateway sends back: the server keeps running the old code until its unit restarts, and the CLI prints that same line. Everything fromupdate.applyonwards is rendered from that answer rather than fromupdate.check, because the re-checks carry on behind it and report this machine as current the moment the swap lands - which would otherwise take the restart command, and a rebuild still running, off the screen. The offer headline gives way to Aether vX is installed. there, since the release is no longer on offer. A-32001(denied) answer is the dialog cancelled or the password refused: the banner shows Update cancelled, nothing was changed. muted rather than as a failure, and the button comes back. Any other refusal is rendered verbatim - the gateway's own message, ending in the command to run where there is one - and the button becomes usable again. - The server banner is for admins, and it acts. Capability is half the
gate and the caller's role is the other half, the same rule the admin
surfaces follow, so it needs
useIsAdmin()as well. It shows the server version and the latest release side by side, and what it offers under that comes fromserver.update_status:- capable: Update now and Update when idle. Update now opens a
confirm dialog that counts the runs active in this member's own run list -
the server's definition of busy, so a paused run and one parked at
needs-attention are not counted - and says what the restart costs: the
runs keep going because the server reattaches to their containers, and
attached terminals reconnect on their own. It then calls
server.updatewithwhen: "now". Update when idle sendswhen: "idle", and the banner becomes Update to vX scheduled by, applies when no run is active with a Cancel that sendswhen: "cancel". - not capable: the documented unprivileged install. No buttons: the
server's own reason, then the two commands to run on the server host with
a copy button, as before. A gateway that does not carry the method keeps
that banner from
update.check'sserver_behind, and says only what it knows - "The dashboard cannot update the server." - The scheduled state also names what the update is still waiting for
(
status.waiting), because a live terminal attach holds it back the same way a working run does.
- capable: Update now and Update when idle. Update now opens a
confirm dialog that counts the runs active in this member's own run list -
the server's definition of busy, so a paused run and one parked at
needs-attention are not counted - and says what the restart costs: the
runs keep going because the server reattaches to their containers, and
attached terminals reconnect on their own. It then calls
- The phases come off the feed.
server.updateevents land on theserverslice throughapplyEvent, once per workspace and once more from the RPC result, so the slice keeps the furthest phase rather than the last one to arrive: scheduled, applying, restarting - the socket drops there, the reconnect re-hydrates and re-reads the status, and a status whoseserver_versionis the version the phases were about clears the progress and ends the banner - or failed, which shows the server's error verbatim and falls back to the manual commands. Every phase is a row in the activity feed too, filterable as Server updates. - Everyone else gets one line. A member who is not an admin sees
server update scheduled, terminals will reconnect briefly (or applying)
in the status bar while one is in flight, so a restart nobody explained
does not read as an outage. A server that does not answer costs the CLI
banner nothing:
update.checkstill returns the CLI half with the failure inserver_error, because the CLI is a binary on this machine and a dead SSH hop is no reason to hide that it is out of date. - Dismissal is per version and it persists.
dismissedUpdateson theuislice records which version was dismissed for each banner and rides the same persisted preferences as the theme and the sidebar, so a dismissal survives a reload, and the next release shows the banner again - without a reload, when a re-check is what brings that release in. It silences the offer, not an update already moving: a scheduled or applying server comes back regardless, because that banner is why the server is about to restart. - The status bar carries the badge. The
aether {version}label gets a dot when either update is available, and clicking it clears the dismissals so the banner comes back - the label is the only always-visible surface, so it is the way back to a banner someone dismissed by reflex. - The desktop shell has a banner of its own. The SPA ships inside the CLI,
but the Electron shell around it is whatever
aether gui buildlast produced.aether gui buildstamps the CLI version into the shell'spackage.json,desktop/main.jshands it to the renderer, anddesktop/preload.jsexposes it aswindow.aetherDesktop.shellVersion. When it differs from theversionthe capabilities descriptor carries, a third banner says the app is out of date and givesaether gui build. It is deliberately not nested in the CLI banner and not keyed onupdate_available: the way a shell goes stale is that the CLI was just updated, which is the moment no update is available any more, so gating it on one would hide it in the only flow it exists for. It renders on the shell stamp alone, so a browser tab never sees it.
Styleguide
- Tokens only. See styles.md for the VS Code-inspired
Light Modern and Dark Modern semantics, interaction tokens,
--state-*tokens, geometry, radii, fonts and the one inline-colour exception for member attributes. Components use semantic token classes rather than route-specific colour literals. - Dark, light, system. The preference is stored, and
systemfollowsprefers-color-schemelive. There is no additional theme mode. - Typography and density. The system UI stack is 13px with 12px supporting copy and 1.4 line height. JetBrainsMono NFM remains terminal and code; VT323 remains only the Aether wordmark and original startup. Use the 35/48/35/22/26/22px workbench geometry and avoid promotional titles or oversized cards.
- Flat shell and palette host. Use a 35px title/command bar, persistent
48px activity rail and adjacent 200-520px workspace/run sidebar. Mount the
command palette once in
AppShell; keep one status Slot for other live contributors. - Two glyphs, two layers. The harness glyph says who is running, the state
dot says what state - never merged into one mark. Presentation states
(
working,waiting,needs-attention,failed,done,idle) are derived insrc/lib/status.ts; the domain status enum is untouched. A group header shows the worst state of the runs under it. - A working run moves.
StateIndicatorswaps the static dot for three dots bouncing in--state-workingon board cards, run headers and run lists. Sidebar rows keep one dot and pulse its opacity; palette rows stay static. The fixed dot box prevents a row shifting when a run starts or stops. - Motion is optional. The steering signal, working dots and sidebar pulse
answer
prefers-reduced-motion: reduceby removing movement. The original shooting-star scene appears only at desktop startup and is skipped under reduced motion. There is no reveal-flash animation. Spinner and skeleton feedback remains available. - Primitives first.
src/components/ui/holds the shadcn/ui pieces:Button,Input,Textarea,Label,Select,Checkbox,Collapsible,Dialog,AlertDialog,DropdownMenu,CommandandSkeleton. These are house copies, retuned to the shared scale andfocusRing, and preserve props, events, refs and accessibility contracts; do not overwrite them with registry defaults. Use them instead of raw form elements. Shared fields and buttons are 26px; compact tools are 22px; adjoining panes and rows are square, and controls use bounded 2-4px radii.Input,TextareaandSelectTriggercompose the sharedfieldstyle with contrast-tuned--inputborders, readable placeholders and explicit disabled/read-only states. - The empty string belongs to the Select placeholder. Use a named, non-empty sentinel for an empty API or filter value, then map it back at that boundary. The Select wrapper ignores the empty report Radix can send through its hidden native select while options arrive.
- Name the Select trigger. Pair the caption's
htmlForwith the trigger'sid, so the control announces its purpose rather than only its current option. - Disclosures normally unmount closed content.
CollapsibleTriggersupplies the marker. The status bar usesforceMountwith closed-state hiding to retain its live contributors while the popup is closed. - HeroUI has a narrow role.
src/components/ui/heroui.tsxwraps HeroUI v3ChipandTooltipand maps them to the house tokens. UseChipfor status or metadata, andTooltipfor supplemental hover and keyboard help. Run and dock tab strips retain their custom manual tab semantics. AChiptakes notitleof its own, so a pill whose text can be clipped wears one on the span around it. - A hint a reader needs is a
Tooltip, not atitle. Atitleis drawn by the pointer and by nothing else, so on a control a keyboard can land on it is information that reader can never get at. A Tooltip opens on focus and hover, with the shared wrapper's 300ms hover delay, and points the control'saria-describedbyat itself. It is a description rather than the name, so an icon-only control keeps itsaria-label. Atitlestays only on what a keyboard cannot land on - a truncated path, a timestamp, a breakdown - since React Aria makes a tooltip trigger focusable, and a hint on a mark would buy a tab stop per feed row or per card. A mark that names itself needsrole="img"first: a bare<span>isgeneric, and ARIA givesgenericno name. - A yes-or-no confirm is an
AlertDialog, not aDialog. The role interrupts rather than announcing a form, it takes the close X away, and an outside click no longer dismisses it. A dialog that asks which of several outcomes to record - closing a run as merged or abandoned - is a choice rather than a confirm, and stays aDialog.AlertDialogActionis destructive unless the caller says otherwise. A confirm that reports its own failure prevents the default on that click and closes on the answer instead, or the refusal would be unmounted with the dialog. - A control a reader still needs is
aria-disabled, notdisabled. Adisabledbutton takes neither focus nor a pointer, so anything it had to say - the full verb behind a shortened label, why a row cannot be opened - goes with it. Those keep their place in the tab order, guard their own handler, and drop the hover the enabled state paints. - One focus indicator, one source.
focusRinginsrc/lib/utils.tsis the shared outline utility. Preserve its 2px outside outline, inset behavior for full-bleed rows and forced-colour visibility. Menus retain roving focus, typeahead, portalling and viewport flipping. Browser checks verify the resulting focus outline; source scans and class-name assertions are not behavior coverage. - Member colour attributes, it does not fill. The avatar rings itself in the member's colour and keeps its initials in the foreground token, because the colour is arbitrary server data with no contrast guarantee in either theme.
- Loading feedback matches duration. Skeletons appear only after ~200ms
(
useDelayed), so a fast response never flashes.
Tests
The dashboard tests use Vitest, jsdom and Testing Library for observable client behavior, with fixtures and stub API/WebSocket transports where a server is not required. Store slices, selectors, token bootstrap, stream lifecycle, terminal attach/reconnect behavior, permissions and error paths remain covered as state transitions. Component tests should assert labels, accessible names, focus handoff, keyboard actions, navigation, loading and empty states, server errors, capability gates and mutation results.
Run actions cover the retained-run contract: Close chooses merged or abandoned,
while Relaunch appears only for an explicitly closed retained TUI Done run
before its TTL expires; expired or unavailable runs have no Relaunch action.
Sidebar tests cover Status and Member disclosure independently, with Done
collapsed by default in Status and every Member group expanded.
src/a11y.test.tsx exercises the run tab strip, dock tabs and sidebar splitter
with keyboard events: arrow navigation, Enter and Space activation, Delete and
Backspace close, focus handoff, clamped resizing and collapse. src/components/shell/nav-shortcuts.test.tsx
covers the shell shortcut precedence across fields, dialogs, menus, lists and
selects. src/components/ui/fields.test.tsx checks that a wrapped Label names
its actual input and that an Input ref reaches the field DOM node. These are
behavior assertions against rendered controls; source scans, literal class
assertions and CSS text checks are not behavior coverage.
Route tests cover the board, run detail, terminal, diff, files, workspace, onboarding, team, members, settings, agents, templates, palette and update surfaces. Keep assertions on what a member can observe: a route or control appears or disappears under its capability and role, a focus or navigation action lands in the expected view, a loading or error message is shown, or the API receives the mutation only after the relevant confirmation. Preserve verbatim gateway errors in assertions when they are part of the contract.
web/e2e/ is the layout and browser-behavior layer. Playwright drives a real
browser against the local aether gui gateway and the server it proxies, while
the same static bundle is also usable through the server-hosted gateway.
keyboard-focus checks Escape ordering across an open dialog and run view and
confirms that a focused control paints the app outline. window-sizing and
status-bar-sizing.spec.ts exercise update notices and status controls at
narrow and desktop dimensions.
The touch shell is driven by the mobile project, which
testing.md describes: shell-drawer.mobile.spec.ts opens the
phone drawer, taps a run and finds the drawer gone with the run on screen;
dialog-anchor.mobile.spec.ts checks that a dialog short enough to tell the
two apart sits at the top rather than the middle, and that the launch form
keeps its footer on screen on a viewport as short as a keyboard leaves;
and toast-clearance.mobile.spec.ts checks that a toast comes to rest above
the status bar rather than on top of it. run-views.mobile.spec.ts steers a
real run from the header's Actions menu and then reads its diff. sidebar-drawer.spec.ts stays on the desktop project because its keyboard contract -
Mod+B closing the drawer and the palette coming back once it is gone - needs
a narrow window with a keyboard rather than a phone. board-card, run-switch,
run-attach-retry,
run-provisioning, terminal-tools and the onboarding scenarios cover the
corresponding real UI transitions, gateway responses and terminal behavior.
Run the full browser workflow with make test-e2e; its scenario inventory and
setup details live in testing.md.
Use the browser workflow for real computed geometry, titlebar and activity-rail behavior, responsive overflow, keyboard focus and gateway-backed transitions. Exercise both themes and narrow and desktop widths against the actual surface; do not pin CSS classes, source strings, token declarations or component plumbing. When changing a visible contract, update the assertion at the layer that can observe it. Use a component test for state, text, role, focus and navigation behavior, and Playwright for computed layout, actual browser focus outlines, responsive overflow, Escape ordering and gateway-backed flows.
Testing on a real phone
The browser suite exercises the local gateway path; the server-hosted gateway and a real touch device are checked by hand. Give the server a dashboard port and restart it:
sudo aether-server config set web-port 443
sudo systemctl restart aether-server
Then open https://<the server's MagicDNS name>/ on a phone joined to the
same tailnet. Expect the board as the first screen, already identified by
WhoIs: no onboarding wizard, no Settings, no link chip, no update banner, and
no pull, forward or sync controls, because the descriptor carries no local
verbs. Files and configuration editing remain available through the
server-hosted gateway.
The installed app is a manual check too, because no browser lets a test
emulate the display-mode: standalone a real install gives. On Android,
Chrome's ⋮ menu should offer Install app; on iPhone, Safari's Share >
Add to Home Screen. The icon that lands on the home screen should be the
Aether mark on a dark tile, and opening it should give a full-screen dashboard
with the shell's own title bar and status bar, safe-area padding intact under
the notch, and the soft keyboard still shortening the layout rather than
covering it. Watch what the server saw with:
journalctl -u aether-server -f
Prerequisites and the refusals a bad tailnet setup produces are in networking.md.
The Android app is the same page in a WebView, so the same expectations hold there. It is worth checking separately for two things the browser does not exercise: whether the title bar and status bar clear the system bars, which depends on the WebView forwarding safe-area insets, and whether the soft keyboard shortens the layout rather than covering it.
make android-debug
adb install -r dist/aether-android-debug.apk
A debug APK allows chrome://inspect from a computer on the same USB
connection, which is how to read the page's console and its computed
env(safe-area-inset-*) values on the device. A release APK does not. The
shell falls back to padding for the system bars itself on a WebView older
than Chromium 140, so check chrome://version on the phone before
concluding the page is wrong.
Missions and Launch Swarm
The launch dialog keeps Single agent as its default. When the gateway
advertises mission.create, it also offers Swarm: one concise objective,
an integrator account/harness/mode, an explicit list of allowed worker
account/harness/mode choices, and finite concurrent and total-attempt limits.
The form sends the exact selected values to mission.create, including a
client idempotency key, then navigates to missions/<server-issued-id>. A
failed retry keeps that key; client-generated IDs are never used as mission
authority. The server bounds these finite limits at eight concurrent attempts
and 128 total attempts; the form rejects values outside those bounds before
sending.
routes/missions is registered through routes/index.ts, and the
MissionsSlice is composed into the root store. Hydration reads
mission.list for the active workspace; mission events refetch either the
open mission.show projection or the current list, so reloads and event
reconnects recover server state rather than retaining a demo snapshot. The
progress view renders the authoritative task statuses Ready, Working, Review,
Done, Proposed and Abandoned. It keeps blockers, exact task revision/scope,
attempt IDs, evidence availability, and accepted submission
revision/artifact references visible. A worker success or exit is not enough
to render Done: the server must accept a submission for the current task
revision, with the required evidence available and any scope disposition
explicitly recorded.
Run links use the existing terminal route. Take control and Release control
continue to enforce the normal run controller and durable worker hold.
mission.worker.release is the human-only release action for a worker hold:
the dashboard sends the run ID and observed
expected_takeover_generation as a compare-and-swap, while the server
rechecks current steering authority under the same admission boundary. A
stale generation, revoked authority, or foreign control holder leaves the
hold in place and surfaces the conflict; an integrator or worker cannot
release it through the assignment-scoped socket. Integrator replacement is
gated by capabilities and role, pins expected_generation, and reuses one
idempotency key across retries while reloading the resulting mission. The
mission marker joins ordinary run cards through the card:badges slot; the
shell and board remain unchanged.
mission.show also carries bounded server-derived scope diagnostics. The
mission task view labels intended overlap, observed overlap and out-of-scope
paths and links each diagnostic to the task or peer run. The same diagnostics
are registered into the existing run-card conflict-chip slot, so overlap
warnings stay in the conflict experience rather than creating a second board
or lock surface.


