ADR-0035: Unified operator shell — scope tabs, activity rail, route-driven chrome, and enforced UI primitives

  • Status: Accepted
  • Date: 2026-06-06
  • Deciders: Ashley (operator/author)
  • Supersedes (in part): app-shell.md Phase 7 (Config/Status/Audit → BottomBar), the global-mode route-bound section TabBar, the project-mode stacked-section sidebar, and the SessionSidePanel Files tab.
  • Amends: app-shell.md, ui/README.md, app-shell-plan.md.
  • Implements pattern from: ADR-0003 (doc-first, then TDD).
  • Note on numbering: bump to the next free sequential ADR number on merge if 0035 is taken.

This ADR is intentionally longer than usual and includes an Implementation section. Per ADR-0003 the finer-grained mechanics still cascade into the specs listed in §10; this document fixes the decisions, the rationale with honest tradeoffs, and the load-bearing implementation contracts (route topology, slot injection, component enforcement) that must not drift during build.


1. Context and problem statement

The current shell (app-shell.md, Phases 1–7 landed) has three structural problems that compound as the product grows:

  1. System config is buried. Global mode is "the route you're on when not in a project." Reaching Providers is BottomBar → Config → tab strip → Providers — four moves from inside a session, and there is no first-class, one-click way back to the system scope. Operators add providers and change daemon settings often enough that this is daily friction.

  2. The sidebar stacks everything at once. SESSIONS + ISSUES + (TERMINALS) + SESSION ARCHIVE are all visible in one 140px rail, so each section is cramped and the prompt — the thing that actually matters — competes with navigation chrome for horizontal space.

  3. Chrome is assembled with conditional logic and components drift. Mode/section behaviour leans on route-prefix conditionals, and screens reconstruct buttons, inputs, dropdowns, and bar items ad hoc. A single design decision (e.g. "dropdowns get an amber focus ring") today means editing many files. This is the most expensive problem long-term.

The operating principle that resolves most sub-decisions: the prompt is everything. Layout choices are judged by whether they give the composer more room and fewer reasons to leave it. A good prompt is how work gets done; the shell exists to serve it.

2. Decision drivers

  • One-click movement between system scope and any project (kill the click-depth).
  • Maximum horizontal room for the conversation and composer; no artificial width caps.
  • A navigation model with proven muscle memory (VSCode / Chrome DevTools), since we are a dev tool.
  • Chrome assembled declaratively by the route tree, not by if (route === X) branches in AppShell.
  • A single source of truth for every visual element — one component per concept, enforced, not aspirational.
  • Operator agency: defaults are opinions; once the operator chooses, their choice persists and wins.
  • Graceful degradation: desktop-first power, a focused mobile subset, and WCO as pure enhancement.

3. Considered options

  1. Keep the current shell, just relocate config. Cheapest. Rejected: doesn't fix the stacked sidebar, the conditional chrome, or component drift — only papers over driver #1.
  2. CodeNomad model — projects as the only tabs, files/editors in the right rail. Rejected: the right-rail editor is unusable (cramped, horizontally scrolling); and it conflates project switching with object switching on one axis.
  3. VSCode/DevTools model — scope tabs on top, activity rail switching a left browser, heterogeneous object tabs in the main area, route-driven chrome, enforced primitives. Chosen. It satisfies every driver, reuses universal muscle memory, and the two tab axes (scope vs object) are genuinely distinct so there is no redundancy.

4. Decision outcome (the shell model)

The shell has five surfaces. All are rendered once by AppShell; none are conditionally constructed — their contents are contributed by the active route subtree (see §5).

4.1 Scope bar (top, full width)

  • Leftmost cell is the 影 system tab: icon-only, locked to exactly --rail width so it caps the activity column beneath it. Then one project tab per loaded project. Active tab gets the amber underline.
  • System inversion is limited to the 影 tab only — amber background, black glyph. The activity rail does not invert (the full-amber rail was too loud). This is a quiet "you're in system" signal, not a shout.
  • Right cluster: the three panel toggles (left / bottom / right, VSCode-style) and an +/settings overflow. Under WCO this cluster sits to the left of the OS window controls.
  • This bar is the WCO drag region. Baseline is an in-page bar at y=0; WCO enhancement lifts it into the title-bar area via the titlebar-area-* env vars and deletes the browser's own title bar. WCO is Chromium-desktop-PWA-only and never assumed — Firefox/Safari fall through to baseline (see ADR background; Firefox can't install PWAs without a CLI shim, so it is never a target for the merge).

4.2 Activity rail (left of the left bar, per-scope)

A narrow icon rail (--rail, neutral in both scopes). It switches what the left bar browses. Badge counts surface attention without opening the list.

  • Project scope: Sessions, Issues (count badge), Files — then an auto-margin spacer — Status, Settings.
  • System scope: Config (cog, primary), Guests — spacer — Status.
  • Status is one icon in both scopes. Selecting it puts two labelled sections in the left barStatus (Health / Subsystems / Resources) and Audit (Events / Permissions / Policies). Audit is not a separate rail icon.
  • Guests is a system rail item and a subsection inside project Settings (project-scoped grants live under the project's settings).

4.3 Left bar (the browser + session dock)

  • Renders the browser for the active activity (sessions list, issue list, file tree, config menu, status/audit sections). One category at a time, full height each — the trade against the old stacked sidebar is "lose at-a-glance, gain room"; badge counts buy back the glance.
  • New session appears as a button below the sessions list, in addition to the + in the section header (the inline close/+ affordances are fiddly; give both).
  • Bottom dock — owned by the active session, not the activity. When a session tab is active, a pinned dock at the foot of the left bar exposes the run params: model · thinking · steps · max_tokens. It persists across activity switches (flip to Issues/Files and it stays) because it is contributed by the session layout, a sibling of the browser region, not by the activity. This is the concrete payoff of the slot model in §5.

4.4 Main area — tabs + well + composer

  • Object-bound tab strip. Tabs are heterogeneous: a session, an issue, or a file, side by side, flipped between like bookmarks. Tabs map to opened state, not directly to navigation events; the set persists (see §5.3). Opening an item from any browser opens its tab.
  • Contextual action cluster to the right of the tabs, keyed off the active tab kind: session → compaction gauge + History + Pause/Stop; issue → Resolve + Bind; file → @-to-prompt. This generalises the existing app-shell.md session top-bar right cluster and per-tab-kind sub-info controls.
  • Well is full width. No max-width anywhere. If the screen has the width, use it — longer lines mean more information on screen. The previous 760px content cap is removed from the conversation, issue, file, and config views.
  • Composer. Present for session tabs (prompt) and issue tabs (comment box: reply or send-to-agent); absent for file tabs (files take the full well). The textarea is borderless and auto-expands to a cap as content grows. Send and Pause are stacked buttons to its right. Opening a file/issue from a session hides the prompt; one click back to the session tab restores it (the prompt-hero / full-room tradeoff, accepted deliberately).
  • Future improvement (noted, not in scope): markdown rendering of message content including fenced code blocks (```lang fences → highlighted blocks). Tracked as a follow-up.

4.5 Right bar (contextual) + bottom strip

  • Right bar: icon bar selecting Details / Usage / Changes (the Files tab is removed — files are main-area tabs now), the mascot above a pinned project Tasks launcher (small button strip, not a browser).
    • Mascot (KarasuMascot): the bracket-framed eagle, winks + slow bounce, auto-shrinks as content above grows, down to nothing. It never lives in the conversation (which fills fast); any in-well watermark must be near-invisible or absent.
    • Default visibility is breakpoint-driven and operator-overridable: open at ≥lg (>1024px), closed at md (768–1024). The moment the operator toggles it, the choice is persisted per-operator and the breakpoint default stops applying. Defaults are opinions; the operator's choice wins.
    • Hidden in system scope — no session/usage/changes/tasks context exists there. (Flippable to show daemon resources later if that earns its place; not now.)
  • Bottom: the old BottomBar dissolves into a thin status strip (state dot · scope · version) plus a toggleable log drawer. Config/Status/Audit nav no longer lives here — it moved to the activity rail / left bar (this supersedes Phase 7).

4.6 Aligned chrome band

Scope bar, and the sub-header row across all three columns (left header · tab strip · right icon bar), share the single --chrome height token (36px) so they form one continuous horizontal band. Matching heights/widths are load-bearing for perceived quality, not cosmetic.

5. Architecture & implementation

5.1 No conditional chrome — route-owned slot injection via portals

AppShell renders the frame and named slot targets only; it contains no if (route === …) chrome logic and stays ≤100 LOC (existing rule, retained).

slots: scopebar.tabs · scopebar.actions · rail · leftbar · leftdock · tabstrip · actions · well · rightbar · status

Each layout route.tsx contributes its chrome by rendering into those targets with createPortal. Lifecycle is then React's own — mount/unmount is tied to the route subtree, so there is no imperative register/unregister, no useEffect add/remove bookkeeping, and no transition-ordering race (the failure mode of a context-registry approach: StrictMode double-register and outgoing-cleanup-vs-incoming-setup flicker). The route tree is the single source of truth for what chrome exists.

The earlier useLeftBar() + useEffect registry idea is explicitly not adopted; the portal-target approach is its more route-native, race-free successor.

5.2 Route topology and the /system reorg

System routes move under a single layout so one route.tsx owns them all:

Old New
/config/aliases /system/aliases
/config/providers /system/providers
/config/plugins /system/plugins
/config/preferences /system/preferences
/guests /system/guests
/status, /audit /system/status (Status + Audit as left-bar sections)

routes/system/route.tsx is a layout that (a) portals the system activity-rail items, (b) flips the inverted-logo theme flag, and (c) owns the children. Project routes keep /projects/:id/*. This is the existing co-located route.tsx pattern (app-shell.md §Co-located tab pattern) applied at scope granularity.

5.3 URL vs state — the one axis split

  • Active object = the URL (deep-linkable: /projects/:id/sessions/:sid, /projects/:id/issues/:n, /projects/:id/files/*). Selecting a tab calls navigate; the route drives slot injection.
  • Open tab set = Zustand, persisted per project (kaged.ui.tabs.<projectId>). Loading a deep link hydrates the active tab and ensures it's in the open set.

This gives deep links, persisted tabs, and pure route-driven chrome simultaneously, with no contradiction. It is also why the session dock (§4.3) persists across activity switches: the activity browser swaps a sibling region; the session route stays mounted.

5.4 State persistence (Zustand → localStorage)

Key Scope Default
kaged.ui.scope.lastProject operator
kaged.ui.left.expanded operator true
kaged.ui.right.open operator breakpoint: open ≥lg, closed md — until toggled
kaged.ui.right.userToggled operator false
kaged.ui.log.open / .height operator false / 240
kaged.ui.tabs.<projectId> operator + project []
kaged.ui.activity.<projectId> operator + project sessions
kaged.ui.leftWidth / kaged.ui.rightWidth operator independent (retain existing)

5.5 Enforced UI primitives — the non-negotiable

A design decision must be expressible as a change to one component. Screens and routes compose primitives only — they MUST NOT construct raw interactive elements or hardcode tokens.

Required primitive families in packages/ui/src/components/ (each a single source of truth, barrel-exported, ≤300 LOC):

Primitive Replaces ad-hoc Notes
Button raw <button> variants: primary (amber/black), ghost, danger
IconButton inline icon <button> square, sized by token
Icon inline <svg> per file single icon registry (Tabler ti-* set)
Dropdown / Select bespoke menus used by dock + composer + config
TextInput / Composer raw <input>/<textarea> borderless, auto-expanding textarea
ScopeTab — (new) the 影/project tabs incl. inversion
RailItem (ActivityRailItem) — (new) icon + count badge + active accent
ActionBarItem inline action buttons the contextual cluster items
Tab bespoke tab markup object tab incl. close + state dot
SidebarItem / SidebarSection retain/extend rows + collapsible groups
PanelToggle — (new) the L/B/R toggles
RightPaneIcon Details/Usage/Changes selector
DockRow — (new) a single run-param control
Badge (BracketBadge) inline [LIVE] spans bracket-lock badge system
CornerBrackets, StateDot, KanjiGlyph, ProjectAvatar, EmptyState retain existing primitives
KarasuMascot inline SVG wink + bounce + auto-shrink

Enforcement (CI, not convention):

  • ESLint no-restricted-syntax banning <button>, <input>, <select>, and <textarea> JSX in src/routes/** and src/screens/** (allowed only inside packages/ui). A raw interactive element outside the library fails the build.
  • A token lint forbidding hex-literal colours outside the token file — colours come from the five-tier surface taxonomy + --amber/--magenta/--cyan vars only (existing rule, now CI-gated).
  • 300 LOC per file; AppShell.tsx ≤100 LOC composition only.
  • Review gate: any new screen PR that introduces a visual element not backed by a primitive must add the primitive to packages/ui first.

6. Consequences

Positive

  • System scope is one click from anywhere; the prompt gets the full width; navigation is muscle-memory.
  • Chrome is declarative and race-free; AppShell is trivial and stable.
  • One component per concept — a design change lands in one file and propagates everywhere.
  • Deep-linkable objects + persisted tabs + per-operator panel prefs.

Negative / costs (honest)

  • Large blast radius: supersedes Phase 7 and several app-shell.md sections; the sidebar, bottom bar, and global tab strip are rebuilt. Mitigated by phasing (§7) — each phase lands independently with screens functioning.
  • The activity rail trades at-a-glance multi-section visibility for per-section room; relies on badge counts to compensate.
  • Two tab axes increase conceptual surface (scope tabs vs object tabs); mitigated by the DevTools precedent and the strict "scope on top, objects below" rule.
  • WCO benefits only Chromium desktop PWAs; everyone else gets the (fully functional) baseline bar.
  • Removing all max-width improves density but demands the primitives and message renderer behave at very wide viewports — covered by the component tests.

7. Migration plan (phases — each lands on its own, doc-first per ADR-0003)

  • A — Primitives + gates. Complete the packages/ui inventory in §5.5; wire the ESLint/token CI gates. No behaviour change.
  • B — Topology + slots. Add the named slot targets to AppShell; move /config/*,/guests,/status,/audit/system/* with the layout route.tsx. Chrome still rendered as today, now via slots. No visual change.
  • C — Scope bar + activity rail + left browser. Replace global/project sidebar nav. 影 inversion, badge counts, status two-section browser, new-session button.
  • D — Object tab manager. Heterogeneous tabs, URL↔store sync, contextual action cluster, session dock injection.
  • E — Right bar + composer + width. Drop right-pane Files tab, relocate mascot, breakpoint+persisted right default; borderless auto-expanding composer; issue comment box; remove all max-width.
  • F — Mobile. Activity rail → bottom nav; right bar → pull-up sheet; /system/* gated out of mobile nav. Three first-class mobile jobs: reply/triage an issue, push a session, quick-edit a file.
  • G — Doc cleanup. Cascade amendments into the specs in §10; delete superseded sections.

8. Mobile (summary; detailed in spec cascade)

The route-driven model collapses cleanly: the project activity rail becomes the bottom nav (Sessions/Issues/Files/Status/Settings), object tabs collapse to a single active view with select/swipe, and the right bar becomes a pull-up sheet. System scope is not surfaced on mobile/system/* exists but the mobile shell omits it ("who cares about settings on the go"). On-the-go scope is deliberately the three quick-fix jobs only.

9. Open items / deferred

  • Markdown message rendering (fenced code blocks, inline formatting) — future improvement, tracked separately.
  • KarasuMascot content-aware auto-shrink — the mascot SVG asset is already implemented (packages/ui/src/components/karasu-mascot/KarasuMascot.tsx) with wink animation, drift, and per-group style overrides. Auto-shrink based on content above is a Phase E enhancement.
  • WCO merge — enhancement layer on the existing scope-bar component; ship after baseline is solid.
  • Right bar in system scope — hidden for now; revisit only if daemon-resource content earns the space.

10. Clarifications (amended post-implementation)

These clarifications were added during the reconciliation pass after Phases A–G landed. They resolve ambiguities in the original ADR that surfaced during implementation. Each is load-bearing — code, tests, and specs must conform.

10.1 Object tab ordinal handles

Tab labels in the ObjectTabStrip use the format {n} · {name} where n is the 1-based ordinal position in the open tab set. Examples: 1 · my-session, 2 · Fix login bug, 3 · src/main.ts.

  • Mod+1..Mod+9 hotkeys activate the tab at that ordinal. Mod = Cmd on macOS, Ctrl elsewhere.
  • Ordinals renumber when a tab closes: closing tab 2 makes former-tab-3 become tab 2.
  • No generic "Session" label — always the session's display name (name field, or fallback like Session {shortId}).
  • Ordinal is visual and hotkey only — the store's tabs[] array order is the source of truth.

10.2 System scope object tabs

System scope uses the same ObjectTabStrip as project scope. System pages (Aliases, Providers, Plugins, Preferences, Guests, Health, Subsystems, Resources, Cost, Events, Permissions, Policies) open as closeable object tabs when navigated to.

  • Tab store key: kaged.ui.tabs.system — the system pseudo-ID occupies the same slot a projectId would.
  • Tab kinds: "system-page" with sourceId = the page slug (e.g. aliases, providers).
  • When all system tabs are closed, the system scope shows its overview/default page.
  • The ObjectTabStrip renders identically in both scopes; the only difference is the tab source (project objects vs system pages).

10.3 Project settings as routes

Project settings are sub-routes under /projects/$id/settings/*, not a single page with horizontal in-main tabs:

Route Left-bar label
/projects/$id/settings (index → redirect)
/projects/$id/settings/general General
/projects/$id/settings/guests Guests
/projects/$id/settings/dsl DSL

The Settings activity in the left bar shows these as menu items (SidebarItem), each navigating to its sub-route. This is consistent with how system Config items work — the left bar is the navigation, not in-main tabs.

10.4 Scope-tab click restores last-active context

Clicking a scope tab (project or system) in the ScopeBar restores the last-active object tab from the persisted tab store:

  1. Read kaged.ui.tabs.<scopeId> from the tab store.
  2. If activeTabId exists and its tab is in the open set → navigate to that tab's route.
  3. If no tabs or no activeTabId → navigate to the scope's overview page (/projects/$id/sessions for projects, /system/aliases for system).

This gives scope tabs a "resume where I left off" behaviour rather than always landing on the overview.

10.5 Two horizontal bands only (session view)

The session view renders exactly two horizontal bands: the scope bar and the object tab strip. There is no third sub-header bar.

  • Session sub-views (subagent stream, file viewer, diff, history, compactor) render inline within the session's content area. They are not separate tab bars.
  • Stop / Pause / Trash actions for the active session move to the ObjectTabStrip's contextual action cluster (the appendSlot), alongside the existing compaction gauge and history toggle.
  • Session name / ID / rename → Details pane in the right panel only.
  • Subagent views are accessed via in-content navigation (e.g. clicking a subagent invocation in the transcript), not via sub-tabs.

10.6 Chrome band alignment

The left bar header, object tab strip, and right icon bar all share --chrome-strip-height (36px) and form one continuous horizontal band beneath the scope bar. This is the concrete expression of §4.6.

  • SidebarHeader height: --chrome-strip-height (not the current h-14 / 56px).
  • The back-chevron (< projectName) in SidebarHeader project mode is removed — scope switching is handled entirely by the scope bar. The project-mode header shows the project name (truncated) as a static label.
  • TabBar height: --chrome-strip-height.
  • Right panel icon bar height: --chrome-strip-height.

11. Spec cascade (to amend on acceptance)

  • app-shell.md — rewrite §Sidebar, §Tab strip (both modes), §Bottom bar, §Project session view; add §Scope bar, §Activity rail, §Slot injection, §System inversion.
  • ui/README.md — reconcile navigation model, breakpoint/right-default rules, primitive inventory.
  • app-shell-plan.md — supersede with the Phase A–G plan above.
  • New: specs/ui/primitives.md — the enforced component contract + lint rules.