# Program Design — Split view for two simultaneous agents (vr7kgx.14) Show two agent sessions side-by-side in the web view, so the user can watch/interact with two agents at once. Each pane owns its own transcript viewport, annotation tray, runtime controls, and pending-request surface; selecting a second session "pins" it to the split instead of replacing the active one. This document is the *how*. It is grounded in the actual code (`src/web`, `src/routes/+page.svelte`). --- ## 0. What already works (and what doesn't) The current tower is a 2-column grid `rack-pane | drawer-pane` driven by a **single** `activeId`. The connection tracks a **single** `selectedSessionId`. Reading the code reveals a crucial asymmetry that shapes this design: **Live events already support multiple attached sessions.** `routeSessionSync` / `routeSessionLive` route by `sessionIdForRoute(agentId)` — a scan of `sessionTabs` for a matching private `routeAgentId` + generation. It is **independent of `selectedSessionId`**. `activateSession` is fenced only by connection `generation`, not selection — the code explicitly notes *"Selection changes do not invalidate an activation that was explicitly issued for this still-open session: its route is session-keyed and remains a valid background attachment."* So two running sessions can both be attached and both receive their live `sync`/deltas today. **Replay is the blocker.** `requestSessionReplay` and `pageToSavedAnchor` fence their results with `isStale(...)`, which compares `selectedSessionId !== sessionId`. A second session that is visible but not *the* selected one cannot replay its history page — its response is rejected as stale, and (for stopped sessions, the common case) there is no activation to fall back on. `restoreSelectedSession` / `maybeBootstrapSessions` likewise restore only the single selected session on reconnect. **Implication.** The split is not a pure layout problem. It requires the connection to treat *two* sessions as focused so both can replay independently, while every existing single-selection invariant (no late response lands on the wrong tab, generation fencing, exactly-once activation/send) is preserved for the non-split case. --- ## 1. Interaction model - **Primary pane** = the session in the existing `drawer-pane` (the focused/active one). Keyboard focus, the composer, and `Enter`-to-send target the primary by default. - **Split pane** = a second session pinned to a new right column. It is fully interactive: its own transcript, composer, runtime panel, recall, and pending-request surface. - **Entering split — "pin".** A pin toggle on each `SessionCard` / tab. Pinning a session that is *not* the primary opens it in the split pane. Pinning the primary session is a no-op (it's already the hero). If a split session already exists, pinning a new one *replaces* the split (the old split returns to a background tab; its attachment is retained if running, replay state is kept). - **Leaving split — "unpin".** Unpinning the split closes its pane and returns to the single drawer. The split session stays open as a background tab (its tab/viewport/route survive). - **Selecting while split.** Selecting (click/Enter) a session that is the **split** swaps primary ↔ split (the two panes exchange roles, focus moves to the now-primary). Selecting any *other* session makes it the new primary; the old primary demotes to a background tab (NOT to the split — the split stays pinned unless explicitly unpinned). - **Closing.** Closing the primary tab promotes the split to primary (if any) or the next open tab. Closing the split tab just un-pins (pane disappears; tab/viewport/route retained). - **Fork origin link.** The fork-origin button in a drawer header still selects the origin session — in split mode that means "make origin the primary". This keeps the existing single-session UX byte-for-byte identical when no split is active: `activeId` + a null `splitId` is the default, and every code path that checks "is split active?" short-circuits to today's behavior. --- ## 2. State model ### 2a. Page layer (`routes/+page.svelte`) Add one sibling to the existing `activeId`: ```ts let activeId: string | null = null; // primary pane (unchanged) let splitId: string | null = null; // split pane (new; null = single-drawer mode) ``` `openTabs`, `viewports`, `runtimeBySession` stay **session-keyed maps** — they already hold state for multiple sessions, so they need no shape change. The split pane simply reads the same maps under `splitId` that the primary reads under `activeId`. Persist the split id alongside tabs/active: ```ts const TABS_KEY = "shard.web.session-tabs"; // { open: string[], active: string|null, split: string|null } ``` `loadPersisted` / `saveTabs` gain a `split` field (default `null` for old payloads). On reload, if `split` references a session not in the catalog yet, the pane waits for the catalog (same deferred-open pattern the primary already uses). **Derived state becomes per-pane.** Today the page derives `activeTab`, `attached`, `transcript`, `pending`, `streaming`, `runtime`, `runtimeControl`, … all from `activeId`. Extract that derivation into a reusable pure helper and call it twice: ```ts function paneState(id: string | null): PaneState | null { if (id === null) return null; const summary = state.sessions.find(s => s.session_id === id) ?? null; if (summary === null) return null; const tab = state.sessionTabs[id]; return { id, summary, tab, attached: tab?.attached ?? false, runtimeActive: tab?.runtimeActive ?? false, transcript: tab?.transcript ?? [], pending: tab?.pending ?? [], streaming: (tab?.runtimeActive ?? false) && summary.runtime?.status === "streaming", runtime: resolveRuntime(id, summary), runtimeControl: state.runtimeControls[id], savedViewport: viewports[id] ?? null, hasOlder: tab?.replay.hasOlder ?? false, loadingOlder: tab?.replay.loading ?? false, loadError: tab?.replay.error ?? null, activationError: tab ? (tab.attached ? null : (tab.error ?? null)) : null, // …statusMessage, lifecycleNotice are global; see §4 }; } $: primary = paneState(activeId); $: split = paneState(splitId); ``` `PaneState` is the exact prop bag `SessionDrawer` takes today; nothing about the drawer's contract changes. This is the linchpin of the design: **the drawer is reused unchanged, instantiated twice, each driven by its own `PaneState`.** ### 2b. Connection layer (`connection.ts` / `session-state.ts`) Add `splitSessionId` to the session-first model, sibling to `selectedSessionId`: ```ts // session-state.ts export interface SessionModelState { sessions: SessionSummary[]; selectedSessionId: string | null; // primary (unchanged) splitSessionId: string | null; // split pane (new; null = none) tabs: Record; } ``` `WebConnectionState` mirrors it (via `withSessionSlice`). The two ids together are the **focused set** — the sessions allowed to replay. Everything else in the model is unchanged: `tabs` stays session-keyed, `routeAgentId` stays private per tab, live routing is untouched. New pure helpers (mirrors of the existing `selectSession`): ```ts /** Set/clear the split pane. Pure. No-op if the session isn't in the catalog. */ export function setSplitSession(state: SessionModelState, sessionId: string | null): SessionModelState { if (sessionId !== null && !state.sessions.some(s => s.session_id === sessionId)) return state; if (sessionId !== null && sessionId === state.selectedSessionId) return state; // can't split the primary if (state.splitSessionId === sessionId) return state; return { ...state, splitSessionId: sessionId }; } /** Swap the primary and split roles. Pure. */ export function swapPanes(state: SessionModelState): SessionModelState { if (state.splitSessionId === null) return state; return { ...state, selectedSessionId: state.splitSessionId, splitSessionId: state.selectedSessionId }; } ``` `closeSessionTab` is extended: if the closed id is the split, clear `splitSessionId` (pane disappears, tab state already dropped); if it's the primary and a split exists, promote split→primary via `swapPanes` then drop the old primary's tab. --- ## 3. The replay fencing change (the key technical risk) `isStale` currently rejects any replay/restore whose session ≠ the single `selectedSessionId`. Generalize it to the focused set **without** weakening any existing invariant: a response is stale iff the generation changed OR the session is no longer in the focused set (i.e. neither primary nor split). ```ts // session-state.ts — was: generation || selectedSessionId !== sessionId export function isStale( atIssue: { generation: number; focusedSessionIds: readonly string[] }, now: { generation: number; focusedSessionIds: readonly string[] }, sessionId: string, ): boolean { return atIssue.generation !== now.generation || !now.focusedSessionIds.includes(sessionId); } ``` `focusedSessionIds` is derived at the call sites as the non-null pair `[selectedSessionId, splitSessionId].filter(Boolean)`. The at-issue snapshot is captured the same way it is today (a copy of the focused set at request time), so a pane being un-pinned mid-flight correctly fences its late replay out — exactly the current behavior for a deselected tab, now generalized to two slots. **Call sites updated (mechanical, ~4 spots):** - `requestSessionReplay` — `selectedAtIssue` becomes `focusedAtIssue`; the stale check uses the pair. A replay for *either* pane now lands on its own session-keyed tab. - `pageToSavedAnchor` — loop fence becomes `!focusedSessionIds.includes(sessionId)`, so the split pane can page to its saved anchor while the primary is also active. - `canContinueSessionRestore` / `restoreSelectedSession` — see §5 (reconnect restores both). **Why this is safe for the single-session case.** When `splitSessionId === null`, `focusedSessionIds === [selectedSessionId]` and `isStale` reduces *exactly* to the current `selectedSessionId !== sessionId` test. Every existing `session-state.test.ts` / `web-session-connection.vitest.ts` assertion about stale replays is preserved by construction — the function is a strict superset of the old behavior over a singleton set. **Activation is untouched.** `activateSession` already fences only on `generation`; the split pane's runtime activation needs no change. `sendSessionPrompt` / `steerSession` / runtime-control commands already resolve the route via `attachedRouteFor(sessionId)` per session, so both panes can send/steer independently today. --- ## 4. Layout The tower grid gains a third column when a split is active. The catalog rail keeps its narrow `minmax(280px,320px)`; the two drawer panes split the remaining width. ```css .tower.has-split { grid-template-columns: minmax(260px, 300px) minmax(0, 1fr) minmax(0, 1fr); } ``` - `has-drawer` (today, single): `minmax(280px,320px) minmax(0,1fr)` — unchanged. - `has-split`: catalog compresses slightly (260–300px) and the two drawers each get `minmax(0,1fr)`. A mid-width guard (`max-width: 1180px` below which split collapses to single, with the split session retained as a pinned background tab) avoids two unreadably narrow chat columns. - Each drawer-pane gets a class distinguishing primary vs split so the visual hierarchy reads (a subtle divider + the split pane's header shows a "pinned" affordance + unpin). Two `` instances render in their own `.drawer-pane`, each `{#key id}`-ed by its session id so the transcript viewport remounts cleanly on pane swap (the existing `{#key activeId}` pattern inside the drawer, lifted to the pane). **Right-panes (recall / dedicated viewer / runtime panel).** Each drawer already owns its own right column. Decision: **each pane owns its own** — recalling in one pane must not steal the viewport of the other. `recallOpen` / `runtimeOpen` become per-pane booleans (see §6); only one of {recall, viewer, runtime} is shown per pane, as today. **Mobile (`max-width: 820px`).** Split is disabled below the threshold (the second pane stacks hidden); the pinned session stays as a background tab the user can tab to. This mirrors the existing single-column stack and the saga's responsive requirement. --- ## 5. Connection command surface (new/changed methods) ```ts class WebConnection { // Primary selection — unchanged signature; now also keeps it in the focused set. selectSession(sessionId, savedViewport?): void; // NEW — open/replace the split pane. Replays history; activates if the catalog reports // the runtime active. Same replay-then-activate sequence as selectSession, fenced by the // (now two-element) focused set. Never changes selectedSessionId. setSplitSession(sessionId: string, savedViewport?: ViewportState | null): void; // NEW — close the split pane (keep the tab/route). Clears splitSessionId. clearSplitSession(): void; // NEW — swap primary <-> split. Pure model swap; replay/activation are already alive on // both, so no re-fetch is needed. Focus moves in the page. swapPanes(): void; closeSessionTab(sessionId): void; // extended: split-promotion logic (§2b) } ``` `setSplitSession` is a near-copy of `selectSession`'s body, but it calls `setSplitSession` (pure) instead of `selectSession` (pure), and the generation/selection fence uses the focused pair. The replay request and the optional post-replay activation reuse the exact private helpers (`requestSessionReplay`, `pageToSavedAnchor`, `activateSession`) — no new request code, just the relaxed fence. **Reconnect restore (`maybeBootstrapSessions` / `restoreSelectedSession`).** Generalize to restore the **focused set**, not just the primary: refresh the catalog, then for each focused id replay its newest page → page to its saved anchor → activate iff its catalog runtime is active. Both panes restore independently. `canContinueSessionRestore` checks membership in the focused set. A pane un-pinned during reconnect correctly fences out. --- ## 6. Action routing — each pane dispatches to its own session Today the page's handlers close over `activeId` (e.g. `routeHandle()` reads `state.sessionTabs[activeId]?.routeAgentId`). With two panes, every handler must target a *specific* session id. The clean way: **parameterize the drawer's dispatch by pane id**, so the page wires one set of handlers that take an explicit `paneId`. `SessionDrawer` gains an optional `paneId` prop (the session id it represents) and re-emits its existing events with that id where the page needs it. Minimal contract change: events that already carry a `sessionId` (`refresh`, `stop`, `kill`, `resume`, `refreshRuntimeModels`, …) already identify the target. The events that don't — `send`, `respond`, `abort`, `sessionOp`, `fork`, `loadOlder`, `stateChange`, `home`, `select`, `closeTab`, `toggleRuntime`, `toggleRecall` — are re-emitted carrying `paneId` so the page routes to the right session: ```ts // page — generic, works for either pane function routeHandleFor(paneId: string | null): string | undefined { return paneId ? state.sessionTabs[paneId]?.routeAgentId : undefined; } function onSend(paneId: string, e) { const handle = routeHandleFor(paneId); if (e.detail.steer) void connection.steerSession(paneId, e.detail.message); else void connection.sendSessionPrompt(paneId, e.detail.message); } function onRespond(paneId: string, e) { /* uses routeHandleFor(paneId) */ } function onAbort(paneId: string) { /* uses routeHandleFor(paneId) */ } function onLoadOlder(paneId: string) { void connection.loadOlderSessionPage(paneId); } function onViewportChange(paneId: string, e) { viewports = { ...viewports, [paneId]: e.detail }; saveViewports(); } // …fork, sessionOp, runtime model/mode setters already carry sessionId. ``` `recallOpen` and `runtimeOpen` move from two page-level booleans to a per-pane map (`Record` or a `{ primary, split }` pair), so each pane's right-column state is independent. `toggleRecall`/`toggleRuntime` carry `paneId`. The Escape handler: Escape closes the **focused** pane's overlay (recall/runtime) first, then that pane's drawer; with both panes open, a second Escape clears the split. Focus tracking (which pane last had focus) determines which pane Escape acts on — a single `lastFocusedPane` ref updated on `focusin`. --- ## 7. SessionRack + SessionTabBar — selection + pin affordances - **SessionRack (`SessionCard` rows).** Each row gains a pin toggle. The rack's `selectedSessionId` prop is augmented with `splitSessionId` so a row can render three states: primary (solid), split (pinned accent), or open/none. The rack dispatches a new `pin` event (session id); the page calls `connection.setSplitSession(id)`. Keyboard: the existing tree navigation is unchanged; pin is a secondary action (e.g. a button in the row, or `Shift+Enter` to pin instead of select). - **SessionTabBar.** Today it shows open tabs with the active one highlighted. In split mode it distinguishes the split tab (a "pinned" marker / different accent) from the primary. Clicking a tab selects it as primary (existing behavior); the pin marker is the split affordance. The bar is shared (one row of tabs for both panes), keeping the mental model: *tabs are open sessions; two of them are visible at once*. Decision: **one shared tab bar** (not one per pane). It already lives in `SessionDrawer`; with two drawers we lift it to the page level above both panes, or keep it in the primary drawer and have the split drawer omit its own. Lifting to the page keeps a single source of truth for the open-tab list and avoids duplicate bars. --- ## 8. Persistence | Key | Shape | Change | |---------------------------|------------------------------------------------------------|--------| | `shard.web.session-tabs` | `{ open: string[], active: string\|null, split: string\|null }` | add `split` (default `null`) | | `shard.web.session-viewports` | `Record` (capped 50) | none — already multi-session | `viewports` already stores per-session anchors for every open tab, so the split pane's scroll position survives reload exactly like the primary's. `runtimeBySession` is in-memory only (unchanged). On reload, `loadPersisted` restores `activeId` + `splitId`; `onMount` calls `connection.selectSession(activeId)` then, if `splitId` set, `connection.setSplitSession(splitId)`. --- ## 9. Responsive behavior - `> 1180px`: full split (catalog + two drawers). - `820px–1180px`: split collapses to single drawer; the pinned session stays open as a background tab (a "pinned" indicator on its tab). Selecting it makes it primary. - `≤ 820px`: existing single-column stack; split hidden, pinned tab retained. The collapse is a CSS-only affordance driven by a `has-split` / `has-split-eligible` class pair; no JS state is lost when the viewport shrinks — the split id stays set and restores when width allows. --- ## 10. Tests **Pure (`session-state.ts` / new vitest or extend `session-state.vitest.ts`):** - `setSplitSession`: sets/clears; no-op on unknown session; no-op splitting the primary; no-op when unchanged. - `swapPanes`: swaps ids; no-op when no split. - `isStale` with a focused pair: stale on generation change; stale when session leaves the focused set (un-pinned mid-flight); **not** stale for the split session; reduces exactly to the old single-element behavior when `splitSessionId === null` (parametrized over the existing stale-replay cases to prove no regression). - `closeSessionTab` extended: closing split clears `splitSessionId` and keeps the primary; closing primary promotes split→primary; closing primary with no split selects next tab. **Connection (`web-session-connection.vitest.ts` + new):** - Two sessions replay independently: select A (primary), setSplitSession B; both receive their newest page; paging-to-anchor works for B while A is focused. - Live events route to both: attach A and B; `sync`/deltas for each land on its own tab regardless of which is primary. - Un-pinning B mid-replay fences B's late replay out (isStale), A's in-flight replay still lands. - Reconnect restores both focused sessions (history replayed, runtime activated iff catalog-active); un-pinned-during-reconnect is fenced. - Swap panes: no re-fetch, both transcripts intact, route handles unchanged. - Exactly-once send/steer/abort per pane; closing a pane mid-activation discards its queued prompt + buffered records (existing invariant extended to the split pane). - No `agent_id` leaks into UI state (existing assertion extended to both panes). **Component (Testing Library + jsdom, `web-session-catalog-ui.vitest.ts`-style):** - Two `SessionDrawer` instances render from two `PaneState`s; each shows its own title, transcript, composer, pending requests. - Pin toggle on a rack row / tab opens the split; unpin closes it; selecting the split swaps panes; closing the primary promotes the split. - Per-pane right-panes are independent (recall in pane A doesn't affect pane B). - Shared tab bar marks primary vs split distinctly; keyboard a11y (tabs pattern) intact. - Responsive: below the split-eligible width, the split collapses with the pinned tab retained. **DESIGN.md.** Update the §Topology section: `tower-grid(rack-pane | drawer-pane)` → `tower-grid(rack-pane | drawer-pane | split-pane)`, document the focused-set replay model, the `splitSessionId` state, and the responsive collapse. --- ## 11. Boundaries & risks - **No protocol change.** Split is a client-only layout + connection-fencing change. The daemon's session/agent APIs are untouched; the private `routeAgentId` still never leaves the connection. Each pane dispatches by durable `session_id`. - **No new attachment limit policy.** The daemon already permits multiple interactive attachments (the code path exists). Split uses at most two. If a deployment restricts attachments, the second pane degrades gracefully to read-only history (replay-only) and surfaces the activation error in-pane — no new client policy. - **Fencing is the hot spot.** Every change to `isStale` / the restore path is paired with a parametrized test proving the single-session behavior is byte-identical. The focused set is always ≤ 2, so complexity is bounded. - **No out-of-order pane switching.** The user can't reorder panes except the primary↔split swap; there is no Previous/Next across panes (matches the FIFO UI-request decision in the sibling saga `vr7kgx.5`). - **No agent_id in UI.** Each pane is identified by durable session id only; the private route handle stays in `SessionTabState` inside the connection. The split never exposes a second routing handle to props. --- ## 12. Implementation order 1. **Pure model + fencing.** `session-state.ts`: add `splitSessionId`, `setSplitSession`, `swapPanes`, generalize `isStale` to the focused set, extend `closeSessionTab`. Unit tests first (§10 pure). This is the load-bearing change; it lands and is green before any layout. 2. **Connection methods.** `connection.ts`: `setSplitSession` / `clearSplitSession` / `swapPanes`; relax `requestSessionReplay` / `pageToSavedAnchor` fences; generalize reconnect restore to the focused set. Connection tests (§10 connection). 3. **Page state + action routing.** Extract `paneState(id)`, add `splitId` + persistence, parameterize all handlers by `paneId`, per-pane `recallOpen`/`runtimeOpen`. 4. **Layout + components.** Third grid column, two `SessionDrawer` instances, shared `SessionTabBar` lifted to the page, pin affordances on rack/tab, responsive collapse. 5. **Tests + DESIGN.md.** Component tests, responsive checks, doc update. Each step is independently shippable and testable; step 1–2 unlock split correctness, step 3–4 deliver the UX.