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.mdPhase 7 (Config/Status/Audit → BottomBar), the global-mode route-bound section TabBar, the project-mode stacked-section sidebar, and theSessionSidePanelFiles 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:
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.
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.
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 inAppShell. - 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
- 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.
- 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.
- 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
--railwidth 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 thetitlebar-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 bar — Status (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 sessionappears 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.mdsession top-bar right cluster and per-tab-kind sub-info controls. - Well is full width. No
max-widthanywhere. 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
Filestab 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 atmd(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.)
- Mascot (
- 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 callsnavigate; 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-syntaxbanning<button>,<input>,<select>, and<textarea>JSX insrc/routes/**andsrc/screens/**(allowed only insidepackages/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/--cyanvars 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/uifirst.
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;
AppShellis 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.mdsections; 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-widthimproves 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/uiinventory 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 layoutroute.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.
KarasuMascotcontent-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+9hotkeys activate the tab at that ordinal.Mod=Cmdon macOS,Ctrlelsewhere.- 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— thesystempseudo-ID occupies the same slot a projectId would. - Tab kinds:
"system-page"withsourceId= 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:
- Read
kaged.ui.tabs.<scopeId>from the tab store. - If
activeTabIdexists and its tab is in the open set → navigate to that tab's route. - If no tabs or no
activeTabId→ navigate to the scope's overview page (/projects/$id/sessionsfor projects,/system/aliasesfor 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 currenth-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.