shard src web plans split-view.md
24 kB · 464 lines
Markdown
at main

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:

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:

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:

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:

// session-state.ts
export interface SessionModelState {
  sessions: SessionSummary[];
  selectedSessionId: string | null;   // primary (unchanged)
  splitSessionId: string | null;       // split pane (new; null = none)
  tabs: Record<string, SessionTabState>;
}

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):

/** 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).

// 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.

.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 <SessionDrawer> 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) #

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:

// 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<string, boolean> 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<sessionId, ViewportState> (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 PaneStates; 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.