Chat-first shell: runs rail, composer-first Home, warm reskin¶
ADR-0019 made the existing chrome usable on a phone, but the information architecture stayed dashboard-first: a horizontal topbar over a repo-card pile, with live runs buried inside the cards and no way to move between runs without going through a list page. Issue #41 (maintainer design interview, 2026-07-08) pivots the app to the Claude-Code-web IA: the conversation list is the spine, starting a run is composing a message, and everything else is secondary navigation. This ADR records the pivot; the issue comment is the full spec.
The decisions, pinned:
- The runs rail is the app's spine on every authenticated page. At ≥1024px
a persistent 260px
<nav>rail; below that the SAME component as a left overlay drawer (hamburger + left-edge swipe to open; scrim / leftward swipe /Esc/ route change to close). Anatomy, top to bottom: brand ·+ New run· ACTIVE (live instances only) · Repos · History · Credentials · Tokens · Settings · username / Log out / SSE live dot. Rows carry no destructive action — Stop stays in the chat header. The rail is driven by the existinglistInstances()+run.changedrefetch (plus a debouncedrun.messages.changedrefetch so conversational-state dots stay live); SSE only, no polling. The old topbar and its hamburger dropdown are deleted. - Attention-first ordering, ended runs drop out. needs-input/question runs
float to the top with an accent dot, working runs follow, idle runs last;
newest
started_atfirst within a group (the API exposes no per-run last-activity timestamp — explicitly out of scope). Dead/ended runs leave the rail; the History page (the old Runs list, renamed,/runsredirects) is the archive. When the drawer is closed on mobile, the hamburger carries an attention badge whenever any live run is needs-input/question. Two continuity details the rows keep from the old instance list: AFK rows show their budget countdown on the secondary line (the deadline lives nowhere else), and since the state dot is color-only, each row's accessible name carries the state word for screen readers. - The Dashboard dissolves;
/is the New-run composer. Home is a big centered composer — repo chip · model/effort chips ·…(optional label) · textarea · send — that never auto-navigates. Send =startInstance→ navigate to the chat; typed text becomes a client-side queued first message that renders as a pending user bubble and auto-sends (via the existing Reply endpoint) exactly once when the transcript becomes ready. No backend change: the queue is an in-memory per-run map that survives SPA route changes and is lost on tab close — accepted and documented in code. Spawn failure returns the text to the composer with the server's 409 verbatim. [Retired 2026-07-10 (#96): the client-side queued first message is gone — the New-run composer text now ridesSpawnSpec.InitialPrompton the spawn argv, so no message ever waits on transcript availability.] Dashboard pieces re-home: repo cards (clone status/progress, stop-all, parked) →/repos; ClaudeAuthCard → Credentials, plus a slim "Claude is logged out — reconnect" banner on the composer surface; the AFK machinery (claimable count · Run one · Auto toggle · three-strikes paused banner with Reset) becomes a compact per-repo strip under the composer, following the composer's repo chip — one surface for both ways of dispatching work. - Manual spawn still takes label/model/effort only. The
…popover holds the label; provider spawn-options stay out because the instances endpoint accepts no options bag (internal/httpapi/instances.go) and this redesign makes no backend change. #21 (the AFK options 2-state-over-3-state checkbox) remains open on its own surfaces. - Warm reskin at token level; one vendored variable serif.
base.cssswaps token values only: bone/ivory surfaces and warm near-black text in light, warm charcoal (not blue-black) in dark, terracotta accent (#d97757). Source Serif 4 (OFL) ships as two self-hosted variable woff2 files underweb/public/fonts/— a static asset, not an npm dependency — and covers headings and assistant chat prose; UI chrome stays system sans, code stays mono. The PWA manifest and theme-color metas follow the palette. Theme selection stays OS-driven — no toggle, no persisted theme state (ADR-0005's principle survives the repaint). Two contrast-driven cuts of the accent:--accent-textis a warm coffee-brown, not white (white on#d97757is 3.12:1 and fails AA), and terracotta used as text — links, active nav, tinted chips — goes through--link, a darker/lighter cut per theme, because the raw accent reads 2.96:1 on bone. - Chat restyle, Claude-style, on top of #35's behaviors. Assistant messages lose the bubble and render as serif prose on the background at full column width; user messages become a soft warm-neutral right-aligned bubble; tool chips flatten to quiet bordered mono rows with the same expand behavior. Accent is reserved for actions and attention (send, interrupt, jump-pill emphasis, attention dots) — never message surfaces. Everything issue #35 landed — quick-return header, jump-to-latest pill, merged composer with the Interrupt morph, in-stream needs-input note — is preserved unchanged.
Status¶
Accepted. A pure SPA + docs diff on top of ADR-0019's foundations: the shell
(AppShell.tsx, SideNav.tsx, lib/railOrder.ts), the composer surface
(routes/NewRun.tsx, lib/queuedMessage.ts, components/AFKStrip.tsx), the
re-homed pages (routes/Repos.tsx, routes/History.tsx, Credentials), the
token/typography swap in base.css, and the chat restyle in RunChat.tsx +
base.css. Dashboard.tsx, TopBar.tsx, InstanceList.tsx,
StartInstanceForm.tsx, and AFKSection.tsx are deleted (the rail, the
composer, and the strip are their successors). No message schema, provider
seam, API, SSE, or migration change.
Considered options¶
- A rail bolted onto the existing dashboard-first app. Rejected by the design interview: it keeps two competing entry points (dashboard cards vs rail) and leaves the dashboard's pile of per-repo controls as the de-facto home. The pivot re-homes those controls where they are used.
- A backend "initial prompt" spawn parameter instead of the client-side queued first message. Rejected for this slice: the Reply endpoint already delivers text to a live session; a spawn-time parameter would thread prompt state through spawn → tmux → provider for no user-visible gain over queue-and-send, and the queue's failure modes (tab close loses it) are accepted and documented. Clean later add-on if the loss window ever matters.
- Recently-ended runs in the rail (a "just finished" section). Rejected: the rail answers "what needs me / what is moving"; History answers "what happened". Mixing them re-grows the dashboard inside the rail.
- A theme toggle alongside the reskin. Rejected again (ADR-0005): OS-driven only. The repaint changes values, not the principle.
- An npm webfont dependency (fontsource et al.). Rejected: two woff2 files and an @font-face are a static asset; a package manager has no business in the font-loading path (no-heavy-deps ethos, ADR-0018/0019).
- Cmd+K quick switcher, run-switch shortcuts, bottom sheets. Deferred
non-goals — the rail plus
/historycover switching; a palette is a clean later add-on.
Consequences¶
- The rail's conversational-state dots inherit the chat tailer's staleness bugs: a running workflow that reads as idle (#39) now lies in the rail the same way it lies in the badge today. Fixing the tailer fixes both surfaces at once — the rail adds no new state derivation.
listInstances()is now fetched by the shell on everyrun.changedand (debounced)run.messages.changedacross the whole app, not just on the dashboard. Chatty transcripts refetch a small JSON list at most ~4×/s — the debounce is the load-bearing guard; keep it if the rail is ever refactored.- The left-edge swipe is best-effort on iOS standalone PWAs (the OS may claim
the edge for history-back on some versions); the hamburger is the reliable
path. Documented in
AppShell.tsx— do not "fix" it by widening the hot zone, that starts eating horizontal scrolls. - The queued first message is the only piece of chat state that lives outside the server (in-memory map). Anything that needs it to survive a reload must move it server-side (the rejected spawn parameter) — do not reach for localStorage, a stale auto-send into a re-used run id is worse than a lost draft.
- E2E and component tests now assume the rail: authenticated page tests stub
/api/v1/instances, and the Playwright smoke asserts the composer's zero-repos empty state on/and Log out in the rail.