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, andEnter-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—selectedAtIssuebecomesfocusedAtIssue; 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 getminmax(0,1fr). A mid-width guard (max-width: 1180pxbelow 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 (
SessionCardrows). Each row gains a pin toggle. The rack'sselectedSessionIdprop is augmented withsplitSessionIdso a row can render three states: primary (solid), split (pinned accent), or open/none. The rack dispatches a newpinevent (session id); the page callsconnection.setSplitSession(id). Keyboard: the existing tree navigation is unchanged; pin is a secondary action (e.g. a button in the row, orShift+Enterto 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.isStalewith 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 whensplitSessionId === null(parametrized over the existing stale-replay cases to prove no regression).closeSessionTabextended: closing split clearssplitSessionIdand 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_idleaks into UI state (existing assertion extended to both panes).
Component (Testing Library + jsdom, web-session-catalog-ui.vitest.ts-style):
- Two
SessionDrawerinstances render from twoPaneStates; 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
routeAgentIdstill never leaves the connection. Each pane dispatches by durablesession_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
SessionTabStateinside the connection. The split never exposes a second routing handle to props.
12. Implementation order #
- Pure model + fencing.
session-state.ts: addsplitSessionId,setSplitSession,swapPanes, generalizeisStaleto the focused set, extendcloseSessionTab. Unit tests first (§10 pure). This is the load-bearing change; it lands and is green before any layout. - Connection methods.
connection.ts:setSplitSession/clearSplitSession/swapPanes; relaxrequestSessionReplay/pageToSavedAnchorfences; generalize reconnect restore to the focused set. Connection tests (§10 connection). - Page state + action routing. Extract
paneState(id), addsplitId+ persistence, parameterize all handlers bypaneId, per-panerecallOpen/runtimeOpen. - Layout + components. Third grid column, two
SessionDrawerinstances, sharedSessionTabBarlifted to the page, pin affordances on rack/tab, responsive collapse. - 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.