ADR 0056 — Unified dockable-view model (tabs ↔ rails)
- Status: Proposed (2026-06-17)
- Date: 2026-06-17
- Deciders: Josh Mabry; protoAgent maintainers
- Tags: ux, layout, appshell, dnd, information-architecture, uiStore, chat
- Related: builds on the DS AppShell rail reorder (
onRailReorder, dnd-kit) and the new DSTabBaronReorder(protoContent #264 — step 1 of this direction). Reframes the surfaces from ADR 0042 (console IA, slug routing), ADR 0044 (plugins contribute rail surfaces), ADR 0045 (the chat panel is a slot), and ADR 0048. Touchesstate/uiStore.ts(railOrder/surface) andchat/chat-store.ts(sessions).
A validated POC (protoContent branch
spike/dock-dnd-tabs, Storybook Spikes/Dock DnD) showed three gestures that feel right: reorder tabs in a panel, tear a tab out to a rail as a top-level panel, and drag a rail panel in as a tab. The DnD itself is a non-issue — dnd-kit already ships in the DS, the rails already reorder, and tab reorder is landing in #264. The blocker is the model: today a "tab" (a chat session inchatStore) and a "rail item" (a surface inuiStore.railOrder) are two different types in two different stores, so tear-out/re-dock means converting between them and there's no shared view identity to move. This ADR proposes one dockableViewtype that can live in either container, with a single layout store — the unification the spike proved is what makes the gestures clean.
1. Context & problem
The console has two placement systems that look similar on screen but are unrelated underneath:
Rails hold surfaces. Left/right/bottom rails render id-addressable views; order lives in
uiStore.railOrder = { left, right, bottom }and reorders today via the DS AppShellonRailReorder(dnd-kit, ADR 0035/0036 context menu as the keyboard path). Surfaces are core (chat,activity,box,settings, …), plugin (plugin:<id>:<view>, ADR 0044), and fork (src/extregistry). A surface is a top-level panel.Tabs hold chat sessions.
ChatSurfacerenders a<TabBar>ofchatStore.sessionsinside the singlechatsurface. A session is a sub-item of one surface, persisted separately (protoagent.chat.sessions[:slug]), not a peer of surfaces. (The Activity/Plugins/Box sub-tabs are a third thing again — single active-id strings inuiStore, fixed sets.)
So "tab" and "rail item" are different types, in different stores, with different persistence and lifecycles. Two consequences block the UX:
- No shared identity to move. Tear-out (tab→rail) and re-dock (rail→tab) are conversions between
chatStore.sessionsanduiStore.railOrder, not a move of one object. Every gesture becomes a special case. - A session isn't a standalone surface. It's one of N inside the
chatsurface. "Tear this session out to the rail" has no referent today — there's no notion of a session existing as its own top-level panel.
The POC sidesteps both by giving every item one View type that lives in either container. That's the missing piece, and it's a model decision, not a DnD one.
2. Decision
Adopt a unified dockable-view model:
- One
Viewtype —{ id, kind, title, icon },kind ∈ { surface, session, plugin, ext }. The content stays in its existing registry (CORE_SURFACES,chatStore.sessions, plugin views,src/ext) and is resolved by id; the View is just the addressable, placeable handle. - One
DockLayout— the ordered placement of view-ids across containers, owned by a single layout store, persisted + migrated:- Containers (v1): the three rails (top-level panels, as today) + the main panel's tab strip (ordered view-ids + active). Multi-panel splits are explicitly out of v1 (see §5/§8).
- A
Viewcan sit in any container; the three gestures are container→container moves of a view-id (exactly the POC).
- One shared
DndContextspanning the rails and the tab strip, so a drag can cross between them. (Today rails andTabBareach own a separate context — unifying them is the main DS lift; see §4.)
Net: tab reorder, tear-out, and re-dock are the same operation — move view-id X from container A at index i to container B at index j — with a small rules layer for forbidden drops.
3. The crux: session-as-View
The hard, non-mechanical part is promoting a chat session to a first-class View (kind: "session"). That generalizes the chat slot (ADR 0045) from "one chat surface containing N sessions" to "N session-views placeable anywhere":
- Tearing a session to a rail = it becomes a top-level panel showing that one session.
- The current
chatsurface becomes a panel whose tab strip holds the session-views — i.e. the default container, not a special case. - This unlocks a real workflow win: two sessions side by side (one torn into the right rail), which is impossible today.
Invariants that must survive the promotion:
- Chat is forbidden in the bottom dock (streaming slot;
App.tsxbounces it back today). Encode as a per-kindplacement rule, not a hardcode. - Always-mounted chat contract (ADR 0045 / #613) — session-views must keep their mount semantics so a streaming turn isn't unmounted by a move.
- Plugin placement (
placement: rail|right|bottom, ADR 0044) and plugin dots (ADR 0039) keep working as view metadata.
4. Model & state
- View resolution. A registry façade
viewFor(id) → Viewover the existing sources (no data migration of the sources themselves). - Layout store. Two paths:
- (B) Bridge first (recommended). Keep
uiStore+chatStore; add a thindockLayoutlayer that references both by id and owns cross-container order + active. Lowest risk; ships incrementally. - (A) Consolidate later. Fold placement into one
dockStoreonce B proves out. Bigger migration; defer.
- (B) Bridge first (recommended). Keep
- Persistence. Per-agent namespaced (matches
protoagent.ui[:slug]/protoagent.chat.sessions[:slug]), versioned with a migration that seeds the newdockLayoutfrom today'srailOrder+ session order (no user-visible reset). - DS lift (the real cost on the component side). The AppShell must expose a dock mode that hosts one
DndContextacross the rails and a panel'sTabBar, replacing the two independent contexts (rails'onRailReorder+ TabBar'sonReorder). This is new protoContent DS work and is the gating dependency for the cross-container gestures (reorder-only works today without it). - Rules layer. Forbidden/placement constraints as data keyed by view
kind(e.g.sessionmay not enterbottom), plus empty-container handling and active-tab fallback (all already in the POC).
5. Sequencing
- Tab reorder in the DS
TabBar— protoContent #264 (landing). Reorder within a strip; useful on its own, no model change. - This ADR — unified
View+dockLayoutbridge + session-as-View + persistence/migration (protoAgent). Makes tear-out/re-dock expressible. - DS AppShell dock mode — one shared
DndContextacross rails + tab strip (protoContent). The cross-container gesture. - Wire protoAgent to dock mode — placement rules, the always-mounted contract under moves, polish.
Steps 1 and 3 are DS (protoContent); 2 and 4 are protoAgent. Each step is shippable; the UX is fully realized at 4.
6. Alternatives considered
- Adopt a docking library (Dockview / FlexLayout / rc-dock / golden-layout). Full split/float/dock semantics out of the box — but it replaces the bespoke AppShell and the investment in it (theming, rails, utility bar — ADR 0044/0046/0048), is heavy, and fights our token system. Rejected for now; the AppShell is load-bearing and the gesture set we want is narrower than a full docking manager.
- Keep two models, special-case tear-out with a bespoke session↔surface converter. The POC showed this is the combinatorial, fragile path; the clean one is unification. Rejected.
- Ship reorder only (no tear-out). Rail reorder exists; tab reorder lands in #264. This is the legitimate stopping point if step 2 isn't worth it — the baseline against which the unification must justify itself.
7. Consequences
- Pro: one mental model; the validated gesture set; sessions become placeable (side-by-side multi-session — a genuine workflow unlock); plugin/ext surfaces and sessions become uniformly dockable.
- Con: touches core layout state + persistence (migration risk); requires a DS AppShell change (shared context); generalizing the chat slot contract needs care.
- Risk: persistence migration for existing users (
railOrder+ sessions); the always-mounted streaming contract under moves; plugin views assuming a fixed placement.
8. Open questions
- Does session-as-View hold the always-mounted chat contract (ADR 0045)? Define mount semantics for N concurrent session-views (and a cap).
- Multi-panel splits — v2, or pulled into v1? (v1 assumes rails + one main panel.)
- Bridge (B) vs consolidate (A) — and when to graduate.
- Placement rules as data per
kindvs hardcoded — and who owns the rule set (core vs plugin-declared).