Skip to content

mobile-ui

The phone-sized face of the board — a touch drill-down of the spec tree plus the shared TimelineChat terminal-free session surface, reusing the same polled data and API routes as the desktop.

The desktop board is a zoomable canvas walked with vim keys, chords, and hover popovers — instruments a phone has no pointer or keyboard for. So a narrow viewport gets a separate, touch-first face rather than a squeezed graph. The choice is viewport width alone (a media query, reactive so a rotate or resize flips it with no reload); both faces read the one polled board, so nothing about the data or the backend forks. The phone face is its own lazy chunk (dashboard-shell): a phone downloads none of the desktop's graph/terminal/annotator libraries.

Not a degraded desktop — a purpose-built terminal-free surface. Pane-backed sessions keep their desktop TUI. A headless session has no pane at any liveness, so both a phone and the desktop console render the persisted session-timeline: declaration notes are the agent's replies, and status transitions plus delivered prompts are the conversation. The chat body is one shared TimelineChat component (timeline poll + board-push refresh + send-then-refresh, with its legacy explicit replyVia:"note" input retained but redundant for a headless target); MobileSessionDetail is only the phone wrapper around it (identity card, back control, and eval entry). Viewport width changes chrome and layout, never the conversation model.

The review and Settings pages are the SAME routed pages, reflowed — never a phone clone. The phone honors the side-nav route family: a #/evals/#/issues address (list or detail) opened at phone width renders the SAME evals-view/issues-view components and their current paged-review request inside the phone shell, restacked by review-chrome's one-column reflow (side metadata above the main column — GitHub's own 390px order); the tab bar grows an Evals and an Issues entry (tapping navigates the route, the active page lights its tab), and Back is the browser's history exactly as on desktop. A direct #/settings address mounts the same settings page and page-scroll above that tab bar even though Settings is not a fifth primary tab. Specs/Sessions stay the phone-local planes below.

One API, never its own. Every read/write the phone makes is a route the desktop already uses, through the shared data.js helpers: the pushed/polled lean board for both planes, the paged Issues/Evals list endpoints only while their route is open, the /api/specs/:id/* panes (content/history/issues/evals — the SAME React pane components, no second markdown or diff renderer), /api/sessions/:id + /timeline for the conversation, and the ONE /api/sessions/:id/input route for sending. Reply readability is derived server-side from the target session's headless harness capability (session-timeline), never from viewport or phone chrome; an explicit replyVia remains only an override input to that shared decision.

The two planes, made native to touch:

  • Specs — the graph becomes a drill-down. A breadcrumb names where you are; tapping a row descends into that node. Each node screen is a compact header over the SAME reading panes the desktop popup renders. A branch opens to its children first; a leaf opens to its spec. The shared #/graph/<node> address (address-routing) is a direct door here too: it restores the target's full ancestor breadcrumb, selects that node's screen, and switches from a routed review page back to Specs without a phone-only URL grammar.
  • Sessions — the list IS the desktop console's list: the same zone partition, the same nesting forest with fold rails/pods, and the ONE shared avatar-less SessionRow face (colour-coded status glyph + live headline) — no mobile-only variant, no second implementation; only the wrapper row is touch-sized. Opening one is the terminal-free conversation: a header (current status + liveness are the board row's, present-tense), the timeline — day-separated, each status event a colored glyph + word + timestamp with the FULL note text beneath, each sent prompt attributed (you / the sending session) — and a docked composer whose input and send action share one stable vertical box (matching top and bottom edges). It uses composer's ComposerTextarea and the one fitTextarea measurement path shared by every dashboard-authored composer: two and three lines grow the input without a scrollbar, while content beyond its declared cap scrolls inside. As a message composer it sends on plain Enter, inserts a line on Shift+Enter, and lets an IME composition Enter commit text without sending, matching the desktop Command Box. The composer stops at the tab bar; .m-tabbar alone owns the viewport-bottom safe-area inset. Its retained silent replyVia:"note" is redundant for a headless target and remains no visible control; the session capability, not this surface, owns the default. The note itself is produced when the agent executes the external spex session <verb> --note CLI; turn-boundary hooks only remind the agent and carry no note data. The timeline's pending state reads the GENERIC loading word — never another surface's loading phrase (it once borrowed the graph HUD's "loading specs from git…", which read as a wrong screen). The detail keeps the conversation tab-less — header, timeline, composer; no tab row spends a line on a list a phone reader never used — but the header carries ONE compact eval entry: a DOOR that is a REAL anchor to the session-scoped Evals list (#/evals?q=is:eval scope:<id>, the address-routing projection as its literal href — one ordinary hash push; session-eval — gates strip, blind spots, ✦-marked own readings, inherited baseline), the same canonical pages the desktop uses, lazily loaded and reflowed to one column. And the conversation is DEEP-LINKABLE: a #/sessions/<id> address (a shared link, or the scoped eval pages' terminal door — evals-view) opens that session's conversation on a cold phone load, one-way route→state — leaving the detail via its back control is phone-local and never rewrites the hash. Reading the measured loss is exactly what a phone reviewer needs; ACTING on it (merge/close) stays desktop scope. The conversation has no second native process view or drill-down door (message-stream): the timeline is the complete terminal-free console. The scroller is chat-shaped but respects the thumb: it opens pinned to the newest entry and follows new ones ONLY while the reader is already at the bottom — a reader parked up in history is never yanked down by the poll (an unchanged poll answer keeps the old array identity, so nothing re-renders at all). Timeline refreshes are likewise interaction-inert: while the composer owns focus, a poll or board push neither replaces that input nor loses its unsent draft; while the reader drags a selection through a note or message, refresh work neither remounts the conversation nor clears its custom highlight. When a terminal-free console becomes active, desktop focuses its mounted composer on the next animation frame so typing can continue immediately; the phone surface never auto-focuses it, because a tab switch must not summon the soft keyboard. The active TimelineChat's composer is its continuous sink: a plain press or drag in conversation text is prevented from moving DOM focus, so that exact textarea remains document.activeElement from mousedown through every move and mouseup. Because suppressing the press also suppresses the browser's native selection start, the coordinate driver follows xterm.js SelectionService's one interaction model: mousedown maps MouseEvent.detail to NORMAL (one click), WORD (two), or LINE (three) and establishes that mode's anchor immediately; there is no independent double-click or triple-click handler after the gesture. Document mousemove is that same gesture's _selectTo: NORMAL extends caret point to caret point after real movement, WORD snaps both ends to the anchor and focus words, and LINE snaps to whole-note boundaries in either direction; mouseup only freezes the resulting Range. A plain click therefore leaves no highlight, a double-click selects one word, a double-click whose second press stays down and drags grows continuously from the first word through the landing word, and a triple-click selects the complete note. Every result is ordinary rendered conversation text painted by ::highlight(timeline-sel) and copyable without ever creating a document Selection. Buttons, links, summaries, roles, and editable controls are outside the driver; their native click actions still land while inert chrome keeps them from stealing the sink.

The composer remains a real focused textarea and its native caret is never re-armed or handed off: printable text, Backspace, Delete, arrows, Enter, paste, and IME input land immediately through the unchanged native editing path even while a custom timeline highlight is visible. A document capture listener owns only the Ctrl/Cmd+C exception, gated to a non-collapsed timeline Range and an empty composer selection; a composer's own non-collapsed selection wins. Keyboard copy and the note copy button enter ONE clipboard seam with the rendered Range/note text. The seam prefers the secure Clipboard API when available, then on API absence or rejection performs the browser's synchronous copy command from that same user gesture while a one-shot copy event writes text/plain into clipboardData. It creates no textarea, document Selection, or focus handoff. Success means the API resolved, or BOTH the fallback event accepted the text and execCommand("copy") returned true; a false/throw/unconfirmed event fails loud through the conversation's restrained live status, keeps the custom highlight, and never flashes a false success. Successful copy gets only a brief acknowledgement. Escape, a new timeline gesture, or a composer press/first edit deletes the custom highlight as visual cleanup, never by moving focus. If Custom Highlight is missing, the surface degrades to its ordinary text/copy affordances without pretending a native selection is safe, and those copy buttons use the same seam and honest result state. The active TimelineChat alone declares that composer as its surface sink; warm hidden headless layers declare none, so two mounted conversations can never route input to the wrong draft. Offline shows an honest can't-deliver hint; a failed send fails loud, keeping the draft. - Create — a touch row above the list opens a full-screen composer: the desktop New Session tab's phone twin, with ALL substance shared through the one launch path (launch.js, split out of the desktop console for exactly this reuse): the raw /preset **node**… grammar request (resolved by launch's backend owner for every caller), the launcher fetch + default resolution + the per-browser remembered launcher choice, so phone and desktop agree — and the one POST /api/sessions. Launching has one configuration choice — the launcher — and every configured profile appears as an ordinary option, with no capability filtering, disabled compatibility row, or placeholder. Only the chrome is phone-shaped — shared ComposerTextarea, native launcher <select>, one launch button. This is a launch composer rather than a message composer: it shares the same auto-growth mechanism, but Enter remains native long-prompt editing and never launches; only the explicit button submits. This matches the desktop New tab and is an intentional boundary, not a missing keyboard handler. Where the desktop box fires in the background and stays type-ready, the phone AWAITS the create with a busy button: the wait is honest (worktree+agent take seconds) and busy-gating doubles as the double-tap guard a touch surface needs. Success returns to the list, where the new row lands on the next board push; a failed create keeps the draft and fails loud. The draft itself lives above the plane, so a peek at the specs tab never loses a half-typed prompt.

It answers "what does the tree say", "what are my agents doing", "talk to them", "start one", and "what did one actually measure" — from a phone. Merge/close and the live pane stay with the desktop board: acting on proposals is the manager cockpit's job, a deliberate scope line.

This node's slice of the shared styles.css is the narrow-viewport mobile face; classes other surfaces add there — most recently the eval tab's .eval-* verdict/transcript rules from the measure-and-score reframe — are those features' churn, not mobile-ui's drift.