Web state layer: per-session stores#
The web frontend keeps its state in one Zustand store instance per session, with genuinely shared data in a single global store. This page explains where each kind of state lives, why the split is drawn where it is, and what goes wrong under any other arrangement.
No frontend expertise is assumed. The ideas you need are introduced first,
then an inventory of the real code with file:line references, then the
design itself in section 6.
1. Three ideas, in plain terms#
A store is a shared box of variables. The web UI uses
zustand, a small library where you create
one object holding both data (currentSessionId, composerDrafts) and the
functions that change it (setCurrentConv, setComposerInput). Any component
anywhere in the page can read any field from that box without it being passed
down through props. The main box is
web/lib/session-store/index.ts:400.
A component subscribes to a slice. When a component calls
useSessionStore((s) => s.conversations), React re-renders that component
whenever conversations changes, and only then. The selector function is the
subscription.
A store can be created more than once. Nothing about zustand requires a
single global box. createStore can be called per session, and a React
context can hand each subtree the instance that belongs to it. That is the
shape section 6 settles on.
A component can be rendered more than once. React components are
templates. <Composer /> appears once in the normal layout and twice in a
split view. Both copies run the same code, so both copies read the same field
out of the same box. If that field is a single global value rather than a map
keyed by session, the two copies fight over it. That single sentence is the
root cause of most of section 5.
2. The main store: field-by-field inventory#
web/lib/session-store/index.ts declares its shape in the ConvState
interface and its initial values in the create<ConvState> call. Every field
below falls into one of three groups. Fields marked removed are listed
because they show what the design rules out; they no longer exist in the code.
Group A — isolated per session#
These fields are Record<sessionId, T> maps. Two sessions on screen cannot
collide, because each reads its own key. This is the shape the global store
converges on.
| Field | Declared at | What it holds |
|---|---|---|
conversations |
web/lib/session-store/index.ts:68 |
sidebar summary per session (keyed, but see Group C — it is a list, not per-session view state) |
messagesById |
web/lib/session-store/index.ts:70 |
every loaded message, keyed by message id |
messageOrder |
web/lib/session-store/index.ts:72 |
ordered message-id list per session |
pendingProjectsByChat |
web/lib/session-store/index.ts:81 |
project chosen for an unsent chat, keyed by provisional chat key |
runningTasks |
web/lib/session-store/index.ts:90 |
per-session running task; drives each composer's send/stop button |
trees |
web/lib/session-store/index.ts:96 |
latest live context tree per session |
tokens |
web/lib/session-store/index.ts:103 |
token usage per session |
contextWindow |
web/lib/session-store/index.ts:112 |
context-window size per session |
heads |
web/lib/session-store/index.ts:115 |
active DAG head (selected branch tip) per session |
additionalWorkingDirsBySession |
web/lib/session-store/index.ts:146 |
extra working directories per session |
composerDrafts |
web/lib/session-store/index.ts:182 |
unsent composer text per session, persisted to localStorage |
composerSettingsBySession |
web/lib/session-store/index.ts:194 |
tool toggles / thinking effort per session, persisted |
contextPanelFor |
web/lib/session-store/index.ts:211 |
which session has the /context popover open — a single field used as a per-session flag; see section 5. Removed: it is now contextPanelOpen on each session's own store. |
pendingDecisions (web/lib/session-store/index.ts:244) is a hybrid worth
calling out: it is a flat FIFO array, but each entry carries its own
sessionId (web/lib/session-store/types.ts:60), and the composer filters
the queue down to its own session at
web/components/chat/composer/index.tsx:344. Functionally it is already
session-scoped; structurally it is a list that every consumer must filter
correctly.
Group B — global singletons whose meaning belongs to one session#
This is the problem set. Each of these is a single value, but the thing it describes is a property of one particular session or one particular pane. With one session visible this is invisible; with two, the second pane either overwrites the first or is forced to read the first's value.
| Field | Declared at | Why it should be scoped |
|---|---|---|
currentSessionId |
web/lib/session-store/index.ts:74 |
"the" active session. With two panes there are two, and one is merely the focused one. |
activeChatKey |
web/lib/session-store/index.ts:77 |
same, for unsent drafts using a provisional local_* id |
runningTask |
web/lib/session-store/index.ts:86 |
deprecated in favour of runningTasks[sid]; kept alive only so legacy setRunning(false) callers kept working. Removed. |
composerInput |
web/lib/session-store/index.ts:178 |
the live draft of the focused session; a mirror of composerDrafts[focused]. Removed. |
composerSettings |
web/lib/session-store/index.ts:193 |
the live settings of the focused session; a mirror of composerSettingsBySession[focused]. Removed. |
composerFocusTick |
web/lib/session-store/index.ts:206 |
a counter bumped to ask "the" composer to focus its textarea; with two composers it is ambiguous which one obeys |
fnFormFunction |
web/lib/session-store/index.ts:217 |
which function's parameter form has replaced the textarea. Belongs to one composer, not the app. |
fnFormPrefill |
web/lib/session-store/index.ts:226 |
prefilled arguments for that form |
fnFormForkOf |
web/lib/session-store/index.ts:227 |
fork anchor node for a re-run |
fnFormClosing |
web/lib/session-store/index.ts:235 |
close-animation flag for that form |
welcomeVisible |
web/lib/session-store/index.ts:165 |
whether the chat area shows the welcome screen — a per-pane condition |
transcriptLoadingId |
web/lib/session-store/index.ts:172 |
holds one in-flight session id; two panes can be loading at once |
branchInfo |
web/lib/session-store/index.ts:62 |
branch chip for "the current conversation" |
statusBadge |
web/lib/session-store/index.ts:65 |
topbar status label; derived from one session's run state |
paused |
web/lib/session-store/index.ts:92 |
pause flag, per running session in principle |
providerInfo |
web/lib/session-store/index.ts:94 |
provider/model shown in the header for the current session |
detailNode |
web/lib/session-store/index.ts:261 |
selected DAG node shown in the right rail |
nodeSelected |
web/lib/session-store/index.ts:271 |
"a DAG node is selected" gate |
detailNode and nodeSelected are listed here because they describe a
session's DAG, but they are deliberately a non-goal — see section 9.
Group C — genuinely global#
These belong to the application, not to any session, and should stay single values.
| Field | Declared at | What it is |
|---|---|---|
wsStatus |
web/lib/session-store/index.ts:48 |
WebSocket connection state |
agentSettings |
web/lib/session-store/index.ts:51 |
Chat/Exec model badges, mirrored from window._agentSettings |
conversations |
web/lib/session-store/index.ts:68 |
the session list for the sidebar (a catalogue of all sessions, not one session's view state) |
rightDock |
web/lib/session-store/index.ts:256 |
right sidebar open/collapsed and which view, persisted to localStorage |
3. The other stores#
Beyond the main session store, web/lib/state/ holds several smaller stores.
None of them is on the critical path for session scoping, but knowing what
lives where prevents duplicating state later.
web/lib/state/center-tabs-store.ts(1020 lines) — the tab strip and pane layout:tabs,activeId,groups,splitWebTabId,splitRatio(web/lib/state/center-tabs-store.ts:129). This is view state and is correctly global: it describes the window, not a session. It is also the store that knows a split exists at all, so it is where the scope tree gets its session ids from.web/lib/state/center-tab-groups.ts— pure functions over the tab layout (grouping, reordering, split panes). No state of its own.web/lib/state/chat-scroll.ts— scroll position helpers keyed by chat key, persisted through a storage interface (web/lib/state/chat-scroll.ts:37). Already per-session by construction, just not inside the store.web/lib/state/functions-store.ts,skills-store.ts,plugins-store.ts— page-level catalogues and their filter/sort/search UI state. Genuinely global; these are settings pages, not sessions.web/lib/state/files-shared.ts— project list, file reads, and file drafts keyed by path (web/lib/state/files-shared.ts:144). Per-file, not per-session.
4. The legacy window.* layer#
There is a second, older state layer that predates the React store: plain
mutable properties on the browser's window object, written and read by the
modules under web/lib/runtime-bridge/. Their types are declared inline, for
example at web/lib/runtime-bridge/chat-handlers.ts:34 and
web/lib/runtime-bridge/conversations.ts:75:
W.currentSessionId— the legacy notion of the active session.W.conversations— a heavy per-session map holding full message arrays, distinct from the store's lightweightconversationssummary map.W.isRunning— a single global "something is running" flag.W.__sessionStore— an escape hatch letting legacy code reach into the React store, used atweb/lib/runtime-bridge/chat-handlers.ts:895.
How the two layers relate#
They are a dual-track system kept in sync by hand. WebSocket frames arrive
in the runtime bridge, which updates the window.* values and writes through
to the React store. web/lib/runtime-bridge/conv-store-mirror.ts:4 documents
this explicitly: the sidebar reads store.conversations, so every mutation of
window.conversations must also call through the mirror to keep the store
authoritative.
Most incoming frames are gated on the legacy global rather than the store. The
pattern data.session_id === W.currentSessionId appears throughout
web/lib/runtime-bridge/chat-handlers.ts (lines 226, 249, 251, 271, 451, 524,
588, 747) and web/lib/runtime-bridge/conversations.ts (lines 332, 387, 527,
530, 536). Each of those is a place where an event for a non-focused session
is dropped or misrouted.
What has already routed around it. The split-view pane does not go through
the legacy shell at all. web/components/chat/peer-session-pane.tsx:1 explains
why: the legacy #chatView shell is a singleton keyed on hardcoded DOM ids
(#chatArea, #chatMessages) read by roughly ten runtime-bridge modules, so
it cannot be mounted twice. AppShell hides it entirely while split
(web/components/app-shell.tsx:552) and each pane renders pure React off the
store instead. The pane even sends its own load_session request
(web/components/chat/peer-session-pane.tsx:66) because nothing in the legacy
path loads a session that is not focused.
What still uses it. Message rendering for the non-split path, session switching, branch badge refresh, and most WebSocket frame routing. The legacy layer is not dead; it is the default path, and the split view is the exception that bypasses it.
5. The failure mode a global singleton produces#
Every Group B field fails the same way:
The component is rendered twice, but the state it reads exists only once.
Two copies of <Composer /> mount. Both run
useSessionStore((s) => s.composerInput). There is one composerInput. So
either both panes show the same text, or the last one to write wins, or a
keystroke in the right pane appears in the left. Nothing in the type system
flags this, because reading a global field is exactly as legal in a split pane
as in a single one. The bug only manifests at runtime, only in split view, and
only after a specific interaction sequence — which is why they surface one at a
time rather than all at once.
Worked example: contextPanelFor#
The /context badge sits inside the composer and toggles a popover breaking
down the session's context usage.
As a plain global boolean contextPanelOpen, the split view renders two
badges, both subscribed to that one boolean. Clicking either badge opens
both popovers simultaneously, and closing one closes both.
The keyed-global answer is to stop storing "is it open" and store which session it is open for:
/** /context floating popover: holds "which session's panel is open"
* (null = closed). In a split view the two composers each render a
* badge, so keying by session is what stops both popping at once. */
contextPanelFor: string | null;
The consumer then compares against its own session id
(web/components/chat/context-badge.tsx:44):
const panelOpen = useSessionStore((s) => s.contextPanelFor != null && s.contextPanelFor === sid);
That is the keyed-global pattern in miniature, and it works: each rendered copy asks "is this mine?" and only one answers yes.
It is also the pattern section 6 declines to generalise. Every consumer still
has to compare against the right id, and a consumer that compares against the
wrong one — or against the focused session — is still type-correct. Under the
per-session store this field is just contextPanelOpen, a plain boolean in
the instance that belongs to the badge's own session, with no comparison to
get wrong.
Mirrors are worse still#
A mirror — a global field holding a duplicate of the focused session's
entry in a keyed map — is worse than a plain global. composerInput and
composerSettings were the two instances of it. Every write has to update
both copies, and every session switch has to swap the mirror over
(switchChat).
A mirror also leaks into components, which must branch on whether they are
bound to an explicit session (web/components/chat/composer/index.tsx:166):
const input = useSessionStore((s) =>
bound === null ? s.composerInput : (s.composerDrafts[bound] ?? ""),
);
The same three-line ternary repeats for fast and unattended, and again
inside composer-session.tsx. Each repetition is an opportunity to get the
fallback wrong, and the bound === null arm makes the wrong answer "silently
use the focused session" rather than an error. A mirror is also something the
window-to-window transfer path (desktop-bridge.ts,
tab-transfer-journal.ts) has to reconstruct.
Under the design in section 6 the scope owns the live value, so there is nothing to mirror, no fallback arm, and nothing for the transfer path to rebuild.
5b. How the industry handles this#
Every application that can show several copies of the same surface at once has to solve this problem. Three approaches dominate.
Approach 1: one small independent store per page. No global store for instance state — each editor/panel creates its own state object when it opens and destroys it when it closes. VS Code is the canonical example: it opens any number of editor groups, each group and each editor instance owns its own state object, mutually invisible; "which editor is focused" is the only top-level fact. Zustand (our state library) officially supports this store-per-instance usage. Strongest isolation; the cost is that data shared across pages (the session list, preferences) needs a separate layer, and the two layers must be wired together.
Approach 2: one global store, keyed by ID inside, with a scope tag on
the component tree. Still a single store, but all instance state lives in
{ id → data } dictionaries; the root of a component tree declares "this
tree belongs to ID x" via React Context (our Provider), and components
inside automatically read their own slot. Redux's official normalization
guidance is this idea; TanStack Query goes further — every cache entry is
keyed, and "the current one" does not exist as a concept. Slack- and
Discord-style multi-channel UIs mostly work this way. Shared and instance
data live in one store, which makes debugging and cross-session features
(sidebar list, global search) easy; the discipline, however, is by
convention only — one sneaky read of the global "current session" pointer
regresses the whole thing.
Approach 3: physical isolation. The browser's answer — every tab is a separate OS process, so state cannot leak even on purpose. Chrome is the extreme case: a browser process owns the global state (tab strip, bookmarks, settings) while each tab's content runs in its own renderer process, communicating only via IPC. Chrome pays that price for a reason specific to it: it runs untrusted third-party code, so isolation doubles as a security boundary (Site Isolation against Spectre-class attacks). In-app split views run the app's own code and nobody pays process-level memory and IPC costs just to keep data apart.
This project chose approach 1 (next section); approach 2 is preserved as the considered-and-rejected alternative at the end of §6.
6. The design: one store instance per session#
Each session gets its own Zustand store instance. Genuinely shared data stays in the single global store.
A component does not name a session and does not read a global active slice.
It reads "my draft", and the enclosing SessionScopeProvider decides whose.
Rendering the same component twice against two sessions is then the ordinary
case rather than the case that breaks: the two copies are subscribed to two
different stores and cannot collide, because there is nothing shared to
collide over.
The split is by ownership, not by storage shape:
| Layer | Contains | Access |
|---|---|---|
| Global store | wsStatus, agentSettings, conversations (the list), messagesById, rightDock, and the durable per-session maps (composerDrafts, composerSettingsBySession, runningTasks) |
useSessionStore |
| Per-session store instance | this session's draft, settings, running, contextPanelOpen |
useSessionScope, inside a SessionScopeProvider |
| View state | tab strip, pane layout, split ratio, which pane has focus | center-tabs-store; belongs to the window, not a session |
The pieces#
web/lib/session-store/session-scope-registry.ts holds the store factory and
a module-level Map<sid, store>. Instances are cached and survive pane
unmount — tabbing away and back keeps the draft you were typing. They are
dropped only when the session itself is deleted (dropSessionStore, called
from removeConversation and dropChatDraft).
web/lib/session-store/session-scope.tsx is the React layer:
SessionScopeProvider sid=… and useSessionScope(selector).
There is no unbound path. useSessionScope throws when no provider
encloses the component rather than falling back to the focused session. A
silent fallback is precisely the bug this layer removes, and it would surface
only in a split view after a specific interaction sequence; throwing makes a
missing wrap obvious on the first render. Two providers exist and between them
cover every composer: FocusedComposer in
web/components/app-shell.tsx wraps the single-session composer with the
focused chat key, and PeerSessionPane wraps each split pane with its own.
Why instances rather than a keyed global slice#
Both shapes stop the collision. The instance-per-session shape was chosen because it removes the possibility of writing the bug, not just the current instances of it. With a keyed global slice every consumer still has to supply the right key on every read and write, and supplying the wrong one — or omitting it and getting the focused session — stays type-correct. With an instance, the key is supplied once by the provider and the component has no way to name another session accidentally.
It also keeps subscriptions naturally narrow: a keystroke in one session notifies only that session's subscribers, with no selector care required.
Durable state still belongs to the global store#
Instances hold live state. Anything that must outlive the tab — localStorage persistence, the tab-transfer wire format, the sidebar's cross-session views — stays in the global keyed maps. The two are kept in step in both directions:
- Scope → global. A scope setter updates its own instance first (so the
pane repaints on the same tick), then writes through to the global keyed
setter. The hooks are installed by the global store at module init via
installScopeWriteThrough, which is what keeps the import one-directional. - Global → scope.
setComposerInputFor,setComposerSettingsandsetRunningTaskForcallpushToSessionStore, so a WS frame, a legacy bridge, or a window-to-window tab transfer lands in the live instance too.
The instance seeds from the global maps on first render, which is what makes remount-after-reload show the persisted draft rather than an empty box.
No mirrors#
Because the scope owns the live value, the global store holds no "live slice
for the focused session" copy of it. There is no composerInput,
composerSettings, runningTask, or contextPanelFor. The focused session's
draft is simply composerDrafts[activeChatKey], computed where it is needed
rather than stored twice.
Considered and rejected: a global store with scope tags#
The alternative was to keep one global store and make every session-scoped
field a Record<sid, T> map, with a React context supplying the key so
consumers read map[scopeKey] rather than a global. contextPanelFor is that
pattern in miniature: it stores which session has the panel open, and each
badge asks "is this mine?".
It works, and it is a smaller diff. It was rejected because it leaves the
failure mode alive: the key is still passed on every access, an omitted key
still silently means "the focused session", and nothing in the type system
distinguishes a correct read from a mis-scoped one. The parts of it that were
right — parallel Record<sid, T> maps rather than one nested sessions
object, messagesById staying flat and global — carry over unchanged, since
those maps are exactly what the instances persist through.
7. Where each Group B field belongs#
Every Group B field becomes a property of SessionScopeState, read through
useSessionScope, with two exceptions noted below.
fnFormFunction/fnFormPrefill/fnFormForkOf/fnFormClosingare onefnFormproperty on the scope. The sidebar and favourites list open a form without knowing which pane should host it, so they name an explicit target session — a genuine behaviour decision, not a mechanical mapping. Consumers:web/components/chat/composer/modes/resolve-mode.ts:18,web/components/chat/composer/modes/fn-form/use-fn-form-state.ts,use-fn-form-wrapper.ts,web/lib/use-pending-run-function.ts,web/components/sidebar/favorites-list.tsx,web/components/sidebar/sidebar.tsx,web/components/chat/messages/runtime-block.tsx, andweb/lib/runtime-bridge/functions-panel.ts.welcomeVisibleandtranscriptLoadingIdare per-session booleans, read byweb/components/chat/welcome-screen.tsxandweb/components/chat/messages/message-list.tsx.composerFocusTickis a per-session counter, so focusing one pane's textarea does not yank the other.branchInfo,statusBadge,paused,providerInfoare per-session. They feed the topbar, which shows the focused session, so the topbar reads the focused scope.currentSessionIdandactiveChatKeystay in the view layer as global focus pointers. They do not mean "the session"; they mean "the focused pane's session", which is whatcenter-tabs-storealready tracks viaactiveId.
pendingDecisions routing depends on this scoping being right: it is filtered
by sessionId at web/components/chat/composer/index.tsx:344, and a
mis-scoped composer would either swallow another session's question or show it
twice.
8. The legacy window.* layer is transitional#
The window.* layer described in section 4 is not part of the design; it is
what the store replaces. It can be removed once all of the following hold:
- Every
data.session_id === W.currentSessionIdgate inweb/lib/runtime-bridge/chat-handlers.tsandconversations.tsis a store write keyed ondata.session_id, so frames for background sessions land instead of being dropped. This requires the store to distinguish "focused" from "the session" first, which is what the focus pointers in section 7 do. window.conversationshas no readers the store's keyed maps do not cover, makingweb/lib/runtime-bridge/conv-store-mirror.tsunnecessary rather than load-bearing.W.isRunninghas no readers;runningTaskscovers it.- The singleton
#chatViewDOM shell is gone, replaced by the React path thatPeerSessionPanealready proves works. This is the heaviest condition — roughly ten runtime-bridge modules address that shell by hardcoded id.
Removing a gate incorrectly drops WebSocket frames silently rather than rendering something visibly wrong, so each gate removal needs a paired verification that the frame still reaches its session.
9. Non-goals#
The right sidebar and the DAG stay single-instance, following the focused
session. detailNode and nodeSelected
(web/lib/session-store/index.ts:261, :271) remain global. Nothing renders
them twice: the right rail is one dock
(web/components/right-sidebar/right-sidebar.tsx:407, :575), and there is no
plan to give each pane its own DAG. They can keep reading the focused session
and be correct.
The same applies to rightDock itself, the topbar badges' display (they show
the focused session by definition), and the settings pages' stores.
The durable maps keep their shape. The parallel Record<sid, T> maps in
the global store stay as they are; they are what the per-session instances
seed from and persist through. Collapsing them into one nested sessions
object is not planned and is not required for correctness.
More than two panes is not in scope. The scope mechanism happens to make N panes work, but nothing here is designed or verified for N > 2.
Appendix: Implementation Status#
The scope infrastructure of section 6 is in place —
session-scope-registry.ts (store factory, instance map, write-through hooks)
and session-scope.tsx (SessionScopeProvider, useSessionScope) — and the
mirrors are gone: composerInput, composerSettings, runningTask and
contextPanelFor no longer exist in the global store, and the wire formats
(SessionTransferSnapshot, ChatTransferState) no longer carry duplicates of
the focused session's keyed entries.
Not yet landed:
- section 7 — the remaining Group B fields still sit in the global store. Each
is a move onto
SessionScopeStaterather than a new mechanism, roughly fifteen to twenty files, decomposable into five independently verifiable commits. The fn-form group is the largest because eight files consume it. - section 8 — the legacy
window.*layer is still the default path for message rendering outside split view, session switching, branch badge refresh, and most WebSocket frame routing. Retiring it spans all ofweb/lib/runtime-bridge/(thirteen modules plus thedagsubdirectory) and is the one piece where partial completion leaves the codebase worse than either endpoint, so it is sequenced last and done per module in one sustained pass.
Related#
composer-interaction-modes.md— how the composer arbitrates between idle, fn-form, question, and approval modesinvariants.md— cross-module UI invariantsinteraction-feedback.md— the optimistic-state rule that shapes how these writes are ordered