Skip to content

session-console

Provenance

  • Source: .spec/spexcode/spec-dashboard/dashboard-ui/session-console/spec.md
  • Source SHA-256: 84afa464bdfa5045cf717bda9d67459237272ee8fe997163ccaa7724ca41eab2

session-console

raw source

Enter on the board opens the session interface; the always-on top-left window (SessionWindow) is the at-a-glance summary. Both are thin views of /api/graph (i.e. spex graph --json): the dashboard renders only what the backend reports and never invents session state, so a human watching the dashboard and an agent driving the same sessions through the CLI see identical state.

A persisted session rename is a graph-stream action, not a private action-refetch: after the route commits the name and nudges the sessions domain, the visible row advances from the delta stream. A concurrent structural full may continue in the background, but the newest session projection reaches the console first and structural convergence must not replace it with an older row. The rename surface may recover a failed write, but a successful rename does not hide the push path behind a second /api/graph request. Archive and close retain their own action/recovery contracts.

expanded spec

The interface is a routed page (#/sessions, [[side-nav]]) — it fills the app's main area beside the navigation rail as a peer of the graph, with no backdrop, no lift, no pop: Enter (from the graph) or the global ⌥2 navigates to it, leaving it is likewise navigation (the rail, ⌥1/⌥3/⌥4, history — never Esc, which stays inside the console's own stack), and its selected tab echoes into the URL (#/sessions/<sel>) so a tab can be deep-linked. Selection validity is the real board session set, not only the currently visible rows: a session hidden under a collapsed nesting parent can still be opened by URL, search, or an originator chip, while ↑/↓ navigation continues to walk only the visible forest rows. Opening such a hidden session from outside the list — including the graph's node menu — automatically unfolds every present ancestor in the console's nesting forest, so the selected row is revealed instead of remaining hidden. Leaving the page never unmounts it — pane-backed terminals keep their sockets and scroll warm, while the selected terminal withdraws its [[live-view]] visibility claim until the shared pane opens again; a headless TimelineChat keeps its rendered timeline cursor, polls only while selected, and resumes from the latest board snapshot when selected again. Open resource tabs follow the same display-hidden lifetime: changing tab, session, or route never unmounts their preview or frame. Page display itself belongs to the shell's shared pane boundary ([[side-nav]]), so the console renders only content and never toggles its own display. The console follows the app theme: its chrome — the session list, right frame, and Command Box — uses the same palette tokens as the rest of the dashboard, so re-theming the app re-themes the console with it (no console-scoped palette remap). The one surface that stays dark on its own is the embedded terminal (--term-bg) — legitimately a dark terminal, whatever the app theme. Two panes: a left session list (its width user-draggable, [[resizable-panes]], with a dense 204px default) and a right area that morphs by what's focused. The list's top button row holds three equal compact pills above the session rows, kept out of the ↑/↓ path down to a session: the New Session button, the archive star ([[archive]] — permanently present, showing no numeric count), and a Search button, the click twin of the ⌥+/ palette ([[session-search]] owns that contract). New and the archive door are mutually exclusive destinations, so opening the shelf clears New's visual active treatment without changing the selected tab. The list is bounded by the routed page's viewport: when its visible zones and rows exceed that height, the list owns the vertical scrollbar instead of growing behind the page pane's clipped edge.

The console list is the mutable home of its session forest ([[session-nesting]]). Dragging a row moves a full-row ghost, dims the original, and highlights a valid receiving parent; a nested row additionally exposes a top-level drop zone. The gesture is deliberately ordinary pointer drag rather than a tiny dedicated handle: the row itself is what will move, so the feedback must visibly be that row. Right-click keeps the complementary explicit remove from parent action for a nested row. Both paths call the one reparent endpoint and leave selection, terminal focus, and invalid/no-op drops alone.

New Session is a centred splash — the [[launch-hero]] block-letter wordmark — over an auto-growing input. Like every dashboard-authored composer, it uses [[composer]]'s ComposerTextarea, whose one fitTextarea measurement path grows through each content line without a scrollbar until the host's declared cap. Composer keyboard meaning is deliberately split by product action: a message composer (TimelineChat conversation or Command Box) sends on plain Enter, inserts a line on Shift+Enter, and never sends the Enter that commits an IME composition; a launch composer (this New tab or the phone's Create screen) is a long-form prompt, so Enter always remains native editing and only the explicit launch button submits. Nothing is prefilled; typing [[ opens the node dropdown (the focused node leads it) — a topic reference ([[mentions]]). A /query token at the caret, at the draft's start or after whitespace, opens the config-preset palette even when the draft already contains prose; accepting it promotes the chosen /<preset> to the draft's start and preserves that prose. The two compose the launch grammar /<preset> [[node]]… <free text>, from which the server derives the node (the first [[<id>]]). Both menus only edit text; the New prompt has no / slash-command menu (presets only). A preset launched with no node target never assumes a node — the agent takes scope from the prompt, else asks first. Submitting launches but never switches tabs: the prompt clears immediately and focus stays in the box — the box never disables or blurs; the launch fires in the background, so the box is type-ready at once and you can fire off several in a row without waiting for each launch's worktree+agent setup (seconds of real work) to finish. Disabling the box for the whole in-flight window was the bug: on a slow or remote launch the entire pane sat greyed and unfocused until the POST and a board re-read returned. You stay on New Session — the new session just appears in the list below (the immediate board refresh, else the next poll, surfaces it). The old auto-jump-to-the-new-session is gone; only a tab's removal (below) ever moves your selection for you.

Beneath the box a launcher pop-out picker is the ONLY launch choice ([[launcher-select]]). A launcher names both the harness ([[harness-adapter]] — Claude vs Codex) and the command/auth profile, so the launch POST /api/sessions carries only launcher; the backend derives harness from that selected profile. The picker is a clean pill button wearing the selected launcher's harness vendor mark + name — no caret, no label; its tooltip points at spexcode.json / spexcode.local.json as the one place launchers change. It opens a centred pop-out card — a viewport-centred dialog over a light backdrop, deliberately not an anchored dropdown — with one row per dashboard-visible launcher ([[launcher-visibility]]) (the row's harness glyph + name, the selected row marked), and beneath each name the profile's configured command in full, as inert read-only text (selectable for copying, but not a control — nothing in the card is clickable except the row select itself; no chevron buttons, no edit surface: config files remain the sole place a cmd is written). Selecting a row closes the pop; a backdrop click or Esc closes it too. Seeded interactive launchers keep the picker present in an initialized project, and configured dashboard-visible profiles add more names. The launcher pick is remembered (per-browser), honors the backend's configured default when there is no remembered valid pick, never assumes a node, and composes orthogonally with the /<preset> [[node]]… text grammar above. The launch substance — that grammar's composition, the launcher fetch/default/remembered-pick, and the one POST /api/sessions — is shared with the phone's composer ([[mobile-ui]]): both send the raw grammar through launch.js, while [[launch]]'s backend owner performs the command-plugin invocation for every caller, including CLI and direct API use. This tab owns only the desktop chrome around it (menus, focus discipline, background fire) and never expands a plugin body itself.

An existing session has one visible base surface. A pane-backed adapter offers two mutually exclusive base surfaces: its live interactive tmux Terminal (SessionTerm), which is the default input surface, and the shared TimelineChat Conversation over [[session-timeline]]. A headless adapter has no pane at any liveness and is always Conversation. The toolbar toggle next to the top-right files control changes a pane-backed session between Terminal and Conversation; the icon always names the other destination, and no second terminal/conversation view is visible at once. The conversation stays mounted after its first visit while an explicit offline state puts the same relaunch panel in front, so resuming reveals the preserved history immediately. That conversation is the whole terminal-free console, with no [[message-stream]] native-event drill-down. The terminal mount and the relaunch panel key on liveness, never the lifecycle label: a session whose process is gone reads offline whatever its authored lifecycle (asking, review, error, …), so it never mounts a tmux client against a dead id (which would leak tmux's bare "no sessions" into the pane) — it shows the relaunch panel instead, offering to resume the same conversation (the transcript and the session's global record survive — see [[runtime]]). queued is the one exception: it has intentionally not launched, so it shows neither a terminal nor a relaunch, and self-starts as a slot frees. Liveness is not the only thing that can claim the surface: an archived session ([[archive]]) shows its archive card instead, keyed on the human's filing rather than on liveness, and that card OUTRANKS both — opening a session you filed away should answer "want it back?" in one button, not drop you into a terminal you deliberately put out of sight, so relaunch stands down and folds its button into the card. Whichever panel owns the surface, the pane behind it must be hidden AND pointer-inert, never merely covered: an absolutely-positioned live xterm otherwise sits on top and swallows the panel's own button while the panel looks perfectly correct. That was found twice, independently, once per panel. The terminal pane is flat: it fills the right area directly — no inner bordered box, no title bar, no nested levels, and no permanently reserved second-input strip. Its own prompt and status line reach the pane's bottom edge. Alt+I suspends [[command-box]] over the lower middle without resizing or reflowing xterm; its fixed footer and upward growth belong to that temporary control surface. Above the pane, one genuinely single-line session toolbar contains the current surface, its local resource tabs, evaluation, and available commands. The current base tab is Terminal or Conversation for a pane-backed session, and Conversation for a headless session. The visual tab sequence is its current surface, resource tabs, the Eval navigation tab, then the resource picker: this is one compact tab rail, with any remaining toolbar space separating it from command tools. A one-pixel divider and short gutter separate the picker from Eval; there is no matching divider after the plus. The picker itself is a compact circular plus control, so it reads as an add/open action rather than an extension of Eval. It is deliberately a step smaller and quieter than a command tool — thin neutral ring, accent only on hover and focus: it opens a menu of things to look at, it does not act on the session, and a control sized and weighted like the merge/stop tools would claim authority it does not have. The plus lists the selected session's posted files and loopback web services ([[files]] / [[web]]) that are not already open. Selecting one creates one browser-local tab for that exact session/reference; closing it removes only that view and permits reopening from the plus menu, never a duplicate. Clicking a filename in the top-right files dropdown uses this same open/select operation for its file row, so it cannot create a separate preview surface or a duplicate tab. A newly observed posted web service creates its one tab automatically, becoming selected only when its session already is. Each resource tab exposes a close icon and a right-side refresh action: for a file it rereads the current preview response, while for a web resource it recreates the same-origin iframe and requests the current local-service response. A selected file also gets download and copy path, the same actions offered by the files dropdown; those file-specific actions do not appear for a web resource. Removing a published reference closes its resource tab. An open resource tab is a warm browser instance, not merely the selected surface: its file preview request or same-origin iframe stays mounted, including its scroll position and page state, while another resource, Terminal/Conversation, another session, or another routed page is selected; returning makes that same DOM instance visible rather than rereading or reloading it. Its lifetime is anchored to its live session, not its selected state: only the tab's explicit close, reference retraction, or session retirement releases it. Resource tabs and that session's warm terminal share this ownership boundary: there is no cross-session resource-tab pool, admission limit, or eviction. A global budget lets one live session starve another while terminals themselves remain unbounded; a per-session quota would still make a session's resources shorter-lived than its terminal for no product reason. Each session may therefore retain every resource tab it opens during that session's lifetime, independently of every other session. A selected resource tab is a temporary browser-local overlay of that session's base surface: closing it, pressing the base tab, or Esc from the resource returns to the same Terminal/Conversation base without changing that preference. The browser persists the pane-backed base choice per session and project; a session without an explicit choice resolves the Settings default, then Terminal. Switching sessions may restore its still-open resource overlay, but resource selection is never persisted or written to the session/backend. Neither console adds a second native-event view. Session identity, lifecycle, and liveness do not repeat here: the selected row in the left session list is the console's visible identity/state surface, so a second headline/status group only spends height and injects volatile prompt/HTML text into aria-label / data-tip. The Eval tab is a REAL anchor whose href is the canonical session-scoped Evals list address (the scoped default query, minted by [[address-routing]]; copy-link/middle-click work for free), so clicking it (or the typed /eval) is one ordinary hash push onto that list ([[session-eval]] / [[evals-view]] — the one canonical home of a session's measured evaluation; the console mounts no eval pane of its own, so the console width is stable and a warm pane is never reflowed; see [[live-view]]). The door carries a compact, symbolic glance over that SAME worktree-rooted session model, already bounded by [[session-eval]] to scenarios this worktree affected or measured. Its four mutually exclusive scenario tallies are the complete visible accounting: reliable current pass/fail counts use [[review-chrome]]'s ReviewState vocabulary, measured stale or legacy/unscored scenarios carry a visible clock tally as work still needing review, and declared-but-unmeasured scenarios remain a visible blind-spot count. The door does NOT repeat a measured/declared aggregate beside those categories: fresh pass + fresh fail + needs review + blind = affected declarations already says the whole thing without a second number. Node-level unknown frontend coverage is a separate missing-state tally, never part of the scenario accounting. The door's accessible name speaks this same complete decomposition; the visible glance is never hidden from assistive technology. Loading, load failure, and zero are distinct states — a transport failure is never painted as zero loss. This is a glance and a door, never a scenario menu or an explanatory paragraph. The glance is the selected graph session row's evalSummary projection; it performs no REST read and owns no timer. Switching tabs or remounting therefore preserves the cached last-known value. An input event first shows updating beside that last-known value, never zero; a stable equal-generation projection becomes current; a compute failure stays explicit with last-known retained. A graph-stream disconnect similarly marks the value last-known until an authoritative reconnect snapshot re-anchors it. ready with every category at zero is the only empty state, distinct from loading, updating, disconnected, and error.

The toolbar wears the app-chrome background with a bottom separator, so it reads visibly apart from the console below it in both light and dark themes (the old flat strip blended into that dark edge — the complaint this replaces). Its exact height follows the real tab text, icon tools, and focus rings rather than clipping them, targeting a compact ~32px instead of the former ~40px identity bar. At a narrow pane the same one-line hierarchy progressively drops secondary Eval tallies while keeping the current surface, resource tab strip, Eval tab, adjacent plus picker, and every currently available icon tool inside the pane. Resource tab labels clip and their strip scrolls horizontally rather than growing a second toolbar line. The bar never grows or overflows for a long prompt/headline because no session headline enters it at all. Geometry stays stable across all app themes, English/Chinese, lifecycle and liveness combinations, and Command Box visibility; a persisted wide session list yields at the desktop/mobile boundary rather than crushing the terminal lane until toolbar controls clip. The TUI owns keyboard input through xterm's native IME-aware path ([[terminal-input]]), while text still selects and the wheel scrolls the tmux pane's real history — normal output through tmux copy-mode, mouse-owning TUIs by forwarding the wheel to the app ([[live-view]] owns the adapter decision), with no browser-owned terminal scrollbar competing with tmux — a drag selects even under mouse-reporting, and ⌘/Ctrl+C copies to the clipboard over HTTPS, localhost, or plain HTTP (past the secure-context-only Clipboard API). Selection changes highlight only: its first and last cells remain legible, and moving an endpoint never shifts the terminal's glyph grid. The browser renderer forwards keyboard data but no pointer reports, so it never enters the application's mouse-report modes; the public terminal parser consumes those mode toggles at the adapter boundary. Pointer drag therefore remains one uninterrupted local selection even when a TUI redundantly reasserts its mouse modes, while wheel navigation continues through [[live-view]]'s explicit tmux-client control path.

The desktop right pane has one console slot with two mutually exclusive base surfaces plus a resource overlay. A pane-backed adapter keeps the warm, input-enabled SessionTerm described here and mounts the same TimelineChat used by the phone on first Conversation visit. A headless adapter mounts only that Conversation, with no terminal placeholder, tmux socket, or [[message-stream]] alternate view. A selected resource tab replaces either base surface with a bounded, top-anchored selectable file preview or same-origin web frame; Markdown uses the same restricted renderer as other dashboard prose while raw HTML remains text. File text is the explicit native-selection exception to the panel's pointer-inert chrome, so drag and Ctrl/Cmd+C work while its non-focusable surface leaves the current terminal/composer sink alone. The inactive terminal layer is hidden and pointer-inert, preserving its warm transport. A selected web resource is its own input sink: on every selection (the picker, its tab, automatic opening for the selected session, or restoring that session's local surface) focus enters its same-origin iframe without an in-page click, so ordinary keys including arrows belong to the published page immediately. The dashboard's reserved controls stay reachable: its documented Alt chords relay back to the console, and Escape first peels the shared [[esc-layers]] top layer (including the open resource picker); only when that stack is empty does it return to the session's Terminal/Conversation sink. Thus a web frame cannot lock the dashboard controls, and one Escape never skips an overlay to switch the resource surface underneath it. TimelineChat's message composer is the shared [[composer]] textarea and auto-growth path, with the same Enter / Shift+Enter / IME-send boundary as Command Box; its docked mobile and desktop hosts do not invent a second textarea mechanism. TimelineChat's composer always sends replyVia:"note": this is the fixed terminal-free surface property, and the note data arrives because the agent executes the external spex session <verb> --note CLI; hooks only prompt the agent at turn boundaries and carry no note data. Session rows still carry only their status and activity vocabulary — no redundant mode badge.

For a pane-backed console, input has two explicit channels. [[terminal-input]] is the default: xterm owns ordinary keys, paste, and browser IME composition and sends its ordered data through the visible terminal WebSocket into the same native tmux client that renders the agent's TUI. Re-selecting the active session or Terminal tab restores xterm focus without first ending its composition. There is no dashboard type mode, general raw-key vocabulary, menu sniff, or per-keystroke HTTP batching; the adapter's one modified-key bridge encodes Shift+Enter as ESC CR, matching Codex and Claude inside true tmux.

Around both channels, console chrome is pointer-inert for focus (the panel-wide blanket; [[terminal-input]] and [[focus-return]] carry the contract): pressing rows, zone headers, parent disclosure rows, the resizer, pills, toolbar buttons, or the launcher pop acts without taking focus, so the current sink — TUI, Command Box, or the New composer — keeps typing focus through any pointer work on the console, and a pop that does take focus returns it on exit. Only the composers' own textareas, the rename input, and the xterm screen take pointer focus.

[[command-box]] is the authored control channel, opened by its resident toolbar icon or the reserved single- modifier Alt+I chord. It floats in the lower middle, never reserves terminal layout, and uses [[composer]]'s fixed footer with upward auto-growth. The draft belongs to the session and survives closing, tab switches, and routing to Evals. Escape or an outside click closes it and returns focus to xterm; an Modified Command/Ctrl/Shift combinations stay with the browser. An Enter that commits an IME composition belongs to the input and never sends; plain Enter sends, while Shift+Enter adds a line.

Command Box dispatches by appending to the target's durable log ([[dispatch]]), never typed into the pane, so one prompt lands atomically even in tmux copy-mode. Its right-pane action-outcome surface shows only the in-flight sending... state. A failed 502 keeps the complete draft and the box open for retry, and carries no delivery marker of its own: a send either put the bytes in the log or did not, so a retry can only ever repeat something that never landed. Once either result settles, it visibly acknowledges through the shared [[transient-notices]] stack — a short-lived delivery/failure result outside the Command Box's geometry — before a successful send clears the draft and closes the box. A / line may instead name a board command, intercepted client-side because sending that word to the agent cannot operate the board. One registry (sessionCommands.js) feeds those rows and every toolbar twin, sharing action, availability, identity colour, localized label, and icon. /stop stops the agent but keeps its resumable worktree; /archive and /unarchive shelve and restore it without stopping anything ([[archive]] — exactly one of the pair is offered, keyed on archived alone); /close removes the worktree; /merge is offered only for the live review proposal declared by done --propose merge; /eval opens the canonical session-scoped Evals page. Lifecycle actions consume both HTTP status and the structured {ok,error} body before the board reloads, so a refused stop/close/relaunch remains visible instead of reading as a successful background no-op. Command Box and lifecycle actions use one selected-session, right-pane action-outcome mechanism only while they are pending: Command Box owns sending... while open; an existing-session action owns working... in its selected action/relaunch panel. Settled delivery and failure publish once through [[transient-notices]], so neither an old refusal nor a success permanently spends console geometry. The left session list is navigation-only and renders no action alert. Bulk archive and close leave select mode immediately but aggregate every returned refusal into that same selected-session result, so an HTTP conflict never exists only in browser tooling. Prompt delivery and a lifecycle transition remain distinct while pending: the former reports sending..., while the latter reports the neutral working...; reusing delivery copy for relaunch, stop, archive, close, or merge would falsely claim the dashboard sent the agent a prompt. There is no /type. Board commands lead the menu tagged [ui] and run on acceptance; live command presets tagged [preset] and harness commands follow as authoring rows that insert their token. Names deduplicate by that precedence. [[node]] resolves at send to the node id plus its live spec.md pointer; @session is a passive [[mentions]] reference retained in the selected session's prompt. File paste, drop, and pick reuse [[file-attach]].

A right-click on a session row opens its context menu — lock on graph, rename, archive or close ([[session-rename]] / [[archive]]), select for bulk archive/close and drag-to-reparent ([[session-multi-select]]), and attach for a live row ([[attach-menu]], which hands over the spex session attach <id> command to join the session's real tmux) — coexisting with the context-menu suppression. Archive and close share the menu's danger group and each confirms before its lifecycle request. Lock on graph locks the board to that session and navigates to #/graph; it has no pending-ops precondition, so an ops-less session still lands on the graph with the lock banner explaining the empty grip. The shared sessionName puts a rename first in the label precedence. Context menus and anchored dropdowns use their border with shallow ambient depth only; they do not cast a bright halo around the menu edge. The row order is automatic — the two-zone grouping below, newest-first within a zone — with no manual drag-to-reorder gesture. Both authored composers accept an attached file (paste, drop, or the paperclip picker — a monochrome inline-SVG glyph in the dashboard's own icon vocabulary, swapping to a spinning ring while uploading, never a colour emoji). Their shared file-attach projection is per file — name, byte progress, final/failure state, and retry or cancel affordance — so one failed item never collapses a batch into a generic spinner. Only a completed backend (= worker) /tmp path is spliced into the composer; transfer protocol, policy, and storage semantics belong to [[file-attach]].

Pane-backed terminals are warm and always connected: every live pane mounts and opens its socket when the console is first entered — never lazily on focus — and stays mounted even while the console is closed, so switching tabs never loses your place (socket + last painted buffer survive), New Session included. A pane-backed Conversation mounts only on its first visit, then remains mounted after deselection or going offline so its timeline cursor and rendered history survive revisits; its refresh timer runs only while selected. Headless sessions follow that same Conversation lifetime from their first selection. Unvisited Conversation surfaces remain inert and make no timeline/detail reads or polling timers. Hidden pane-backed layers remain laid out at the final terminal geometry under visibility:hidden, keeping their xterm and stable default renderer ready; switching changes visibility, not socket attachment or renderer identity. No pane loads a visibility-scoped WebGL addon, so hidden sessions neither expose an empty replacement renderer nor accumulate capped GPU contexts. [[live-view]] owns the matching backend rule: an unselected session, a closed Sessions route, or a background browser tab owns no raw PTY or tmux geometry, while a visited hidden xterm keeps its cached pixels for an immediate return paint. List navigation lives at the window level only when focus is outside xterm and every text input. Plain ↑/↓ therefore walk the list from inert console chrome, while the live TUI and the New/Command Box textareas keep their own arrows entirely. To switch sessions while typing or driving the TUI, use the modifier combos: ⌥+↑/↓ are an unconditional switch — they step the selection up/down the list from anywhere, no matter which input has focus (the guaranteed up/down switch a work console gives you). The same window router reserves ⌥+Shift+↓ to expand and ⌥+Shift+↑ to collapse the selected row's existing [[session-nesting]] fold. It consumes those chords before the ordinary ⌥+↑/↓ session move, so selection never changes; a leaf or already-matching state is a no-op. Unmodified arrows and every editable control keep their native key, and the action never changes session data. A transient overlay's own focused control also keeps its native keys: the window router yields before any New-tab or list shortcut when the event target is inside a data-focus-overlay, so a visible confirm's Enter cannot launch a New Session behind it. ⌥+N reaching the New Session composer is no longer this console's own chord — it belongs to [[side-nav]]'s app-global ⌥ command family (⌥N / ⌥F / ⌥1..⌥5), which the console's key handling deliberately falls through unhandled so the window-level handler routes it and tmux never sees M-n/M-f/M-digit. (The family is ⌥-based for the same hard browser limit that shaped the old chord: ⌘/Ctrl shortcuts remain native/browser-owned, while ⌥ is the modifier the app can actually own.) The toolbar's one right-side tool group renders the same board-command registry. The top-right [[files]] icon is grey when the selected session's projected path list is empty; otherwise it opens a file-name-only list whose full paths live in hover tooltips. It and the pane-backed Terminal/Conversation switch are adjacent icon controls, without a painted divider, wrapper boundary, or extra gutter separating them from the other right-toolbar tools: the whole right edge uses one shared icon gap and one outer padding. Clicking the filename opens or selects the singleton resource tab; the adjacent download and copy tools remain explicit icon actions, with download delegating to the authorized backend route. Command Box is present whenever live. The right-side action group is surface-specific: every selected resource shows its one refresh tool; a selected web has no file download/copy or merge tool, and the Terminal base surface alone shows the 24px merge tool. Merge is green and dispatchable only for the persisted awaiting + proposal:merge + review projection while liveness is online; nothing/done, close-pending, working, asking, and every non-online reading keep the tool muted and disabled, with a localized tooltip and accessible reason. On each activation, the console reads that review authority and sends its branch head, base head, and durable review declaration epoch; its merge idempotency key derives from all three. A retry in one epoch therefore reuses one response-only delivery receipt, while an agent's renewed awaiting + proposal:merge declaration advances the epoch and gives unchanged heads a distinct authorized delivery. Disabled merge never appears as a typed /merge command and never dispatches. Command Box is the resident tool and always sits at the group's right edge; merge and relaunch occupy the fixed tools to its left on the Terminal surface, so proposal/lifecycle/liveness changes do not move merge. Every visible action uses one shared compact icon-toolbutton primitive and a familiar [[icon-system]] / Lucide mark (command, git-merge, rotate/relaunch), with its registry identity colour; there is no emoji, visible text label, or toolbar-local icon/action mapping. The registry remains the single row that decides availability, colour, typed twin, localized tooltip/aria-label, pressed state, and execution. Command Box exposes aria-pressed plus a stable selected treatment; an offline liveness (any lifecycle) also exposes the same primitive's relaunch action, and review is agent-proposed at the stop-gate. The evaluation is no longer one of these buttons — it is the permanent Eval navigation tab, always available for any selected session (see [[session-eval]]): the toolbar entry or Command Box /eval, each navigating to the session-scoped Evals page. The reserved Command Box chord is consumed but inert for offline/queued sessions, using the same registry judgment as the button. There is no close/exit button here (neither has a button twin — a strip "close" misreads as "close the panel" while it discards the worktree): the destructive close (worktree removal) lives only on the row's right-click menu, behind a confirm ([[session-rename]]); both verbs are otherwise reachable as the typed /stop·/close commands above. Closing is event-driven: the tab's removal — not any one gesture — drives where you land. Still on the closed tab → New Session; already moved to another valid tab → your switch stands. The same fallback covers a session that ends or is closed elsewhere, so the selection never points at a session the board no longer has.

SessionWindow is the read-only glance, built from the shared SessionRow face ([[session-activity]]) in the SAME compact one-line, zone-grouped layout as the console list — but KEEPING the avatar (its cross-referencing job) and the board's warm paper: the avatar + the session headline (the worker's live tmux self-summary once it exists, else a launch-prompt placeholder; a rename always wins) + a single colour-coded status glyph + pending-op count; the session's launcher remains durable data on the API payload but is not rendered as a per-row badge, keeping the glance clean. On one line, with a monochrome inline-SVG padlock (the dashboard's own glyph vocabulary, not a colour emoji) at the headline's end when the row is locked. It stays a bounded glance: the window never grows into a curtain — its height is capped (~80% of the viewport, and always stopping short of the bottom stats strip), and a long session list scrolls inside it rather than extending down over the board's stats bar. A single click locks the board onto that session (overlays light, rest grey, focus jumps to its first changed node, see [[keyboard-nav]]); a no-overlay session still locks un-greyed; a second click releases; double-click opens its board (mouse-side ). The interface's own tabs render the same SessionRow with different gestures: single click switches tab, while double-click has no separate meaning and therefore only leaves that tab selected. Locking from the console is the row's explicit right-click → lock on graph action above, not a hidden double-click gesture. The console renders the row in its compact, avatar-less variant (showAvatar={false} compact): the console's own left list is a dense one-line-per-session list at rest, with a 204px default width (15% below the former 240px) and caption-size row text; the selected headline may expand in place to at most three lines, with its complete text retained in the tooltip/accessibility name. The status is a single colour glyph, not a word. The avatar is dropped ONLY here — its cross-referencing job (matching a session to the avatars on the nodes it edits) belongs to the map-side SessionWindow, which keeps it. The list itself groups into three triage zonesneeds you (asking / review / done / close-pending / error) over running (working / parked / starting / queued …) over offline (dormant, at the bottom), a dim header leading each — and within a zone the newest session sits on top. The offline zone is keyed on liveness, not the authored lifecycle: a session whose process died while it was asking/review/error keeps that pre-death lifecycle, yet it cannot act until relaunched, so it sorts to offline rather than wrongly sitting under needs you; a merely booting session (starting/queued) stays under running. The offline zone rests folded behind its own header — the ONE disclosure for session history. Its header is a single row with the COUNT badge first and the OFFLINE label second; it contains no >/chevron/caret/ direction symbol. Retired and dormant sessions accumulate (an adopter's CR record sessions are deliberately kept alive for their external deep links), and a list that renders every one of them drowns the two zones a human acts on; but they are records, so they are never deleted and never more than one click away. The header's leading COUNT pod is the only disclosure control: it carries aria-expanded, toggles the zone, and stays pointer-inert for focus. The adjacent OFFLINE label is inert; clicking anywhere else in the header never changes the fold. A parent row with sub sessions uses the same grammar: its child-count pod is the first content before the title/status body, never a trailing action, and that pod alone toggles its children and carries aria-expanded. Clicking the rest of the parent row performs that surface's ordinary row action (select/open in the console or phone, graph lock/open in SessionWindow) without changing the fold. The disclosure pod and row action are sibling controls in the DOM, never a button nested inside another button. Neither surface renders a directional glyph for parent disclosure; hierarchy is communicated only by the count's leading slot, indentation, and the resulting row structure. Folding is presentation only (per-surface state, collapsed again on a fresh mount; no session record is touched), it applies to no other zoneneeds you and running rows can never be hidden by any fold — and the selected session stays revealed: a row chosen by URL, search, an originator chip, or the graph's node menu renders even while its zone is folded, so a deep link into history always lands on its visible row. ↑/↓ walk only the visible rows, as with every fold. The selected row is marked by the highlight wash alone, no caret. The SessionInterface sidebar, SessionWindow, and phone Sessions list share this grouping + compact one-line layout; only the avatar differs (the map-side window keeps it, the console and phone lists drop it).

All surfaces share name and status from session.js, whose single STATUS_COLOR map paints the liveness dot, the status word, and the compact sidebar's status glyph (STATUS_GLYPH) the SAME hue everywhere they appear (window row, console sidebar row, @-mention and search rows, the mobile card). The toolbar deliberately carries none of these identity/status marks. Deliberately just four hues — a traffic light plus grey: green = on track, no action from you (working, or parked — paused to self-resume), yellow = waiting on YOU (asking/review/done), red = error, grey = stopped/dormant (idle/starting/queued/close-pending/offline). The colour only answers does this session need me? so a glance sorts the board without a legend; the word still spells the exact state. Green for working also matches the avatar's liveness ring, so dot, word, and ring never disagree.