9.7 KiB
Sync architecture, event handling & store update rules
Scope
This document covers the current client-side session/data architecture in packages/ui/src/sync and the rules for updating stores safely.
There are two distinct session data scopes in the UI:
-
Directory-scoped sync stores
- Owned by the sync layer child stores created in
sync-context.tsx - Source for per-directory live session/message/part/permission/question state
- Backed by SSE / directory-scoped polling
- Read via hooks like
useSessions(),useDirectorySync(),getSyncSessions(),getDirectoryState()
- Owned by the sync layer child stores created in
-
Global sessions cache
- Owned by
packages/ui/src/stores/useGlobalSessionsStore.ts - Shared source of truth for the Sessions sidebar global lists and Session Retention cleanup
- Holds:
- global active sessions
- global archived sessions
- active sessions indexed by directory
- Owned by
These two scopes are intentionally different, but they are no longer equal peers for live UI truth.
Why both exist
The directory-scoped sync stores are not a complete global view.
- They are created lazily per directory
- They only contain data for directories initialized in the current app session
- They are optimized for live per-directory domain data
- They do not maintain the complete global active+archived session view needed by the sidebar and retention settings
So:
- Use the directory sync stores for per-directory live session/message state
- Use the global sessions store for cold/global session coverage (especially archived pages and unopened directories)
- Use aggregated child-store snapshots for live session/status truth across already initialized directories
Ownership map
| Layer / Store | Owns | Scope |
|---|---|---|
child directory stores in sync-context.tsx |
session, message, part, permission, question, etc. |
One directory |
session-ui-store.ts |
Session selection, draft lifecycle, abort prompts, worktree metadata, SDK-facing action entrypoints | App UI state |
useGlobalSessionsStore.ts |
Global active sessions, global archived sessions, sessionsByDirectory |
All opened project/worktree session lists |
viewport-store.ts |
Scroll anchors, session memory, loading indicators | App UI state |
input-store.ts |
Draft input state, attached files, synthetic parts | App UI state |
selection-store.ts |
Model/agent/variant selections | App UI state |
voice-store.ts |
Voice state | App UI state |
Session list rules
Directory-scoped session list
Use the directory-scoped sync store when the UI needs the live session list for the current directory.
Examples:
- current chat/session switching
- per-directory session/message bootstrap
- session/message/part SSE updates
Global session list
Use useGlobalSessionsStore when the UI needs a shared global session cache.
Current consumers:
useSessionAutoCleanup.ts
Live cross-directory session/status view
Use the sync hooks backed by aggregated child stores when the UI needs live truth for sessions or statuses across all initialized directories.
Current consumers:
SessionSidebar.tsxSessionNodeItem.tsxHeader.tsx- agent/session activity surfaces using
useGlobalSessionStatus()/useAllSessionStatuses()
Mutation responsibility
useGlobalSessionsStore is not maintained by SSE directly. It is kept correct by:
- shared global fetch/reconciliation via
loadSessions()/refreshGlobalSessions() - direct mutation from session actions after successful SDK calls:
- create
- title update
- share
- unshare
- archive
- delete
- retention cleanup batch archive/delete
This keeps cold/global lists responsive without requiring a refetch after every change.
Live activity/status indicators must not depend on this cache. They must derive from aggregated child-store state.
Session action rules
Session actions live in session-actions.ts and are the canonical place for SDK-calling session mutations that affect global session lists.
Rules:
- If an action mutates session list membership or visible session metadata, update
useGlobalSessionsStorethere. - If an action targets a session by ID, resolve the session's own directory. Do not assume the current directory is correct.
session-ui-store.tsshould delegate tosession-actions.tsfor these mutations instead of duplicating SDK calls.
Examples of global-store updates performed in session-actions.ts:
createSession()->upsertSession(session)updateSessionTitle()->upsertSession(result.data)shareSession()/unshareSession()->upsertSession(result.data)archiveSession()->archiveSessions([id], archivedAt)deleteSession()->removeSessions([id])
The golden rule
When creating a draft in handleDirectoryEvent, only clone the state fields the event will mutate. Never spread all fields eagerly.
// WRONG — clones everything, breaks referential equality for all subscribers
const draft = {
...current,
session: [...current.session],
message: { ...current.message },
part: { ...current.part },
permission: { ...current.permission },
// ...
}
// RIGHT — only clone what this event type touches
const draft = { ...current }
switch (event.type) {
case "message.part.delta":
draft.part = { ...current.part }
break
}
Why this matters
Zustand skips re-renders when a selector returns the same reference (Object.is). If you spread session: [...current.session] but the event only modifies part, the session array gets a new reference. Every component using useSessions() re-renders for nothing.
During streaming, message.part.delta fires ~60 times/sec. Eagerly cloning all fields caused every subscriber in the entire app to re-render 60/sec — a 10x overhead. Targeted cloning reduced MessageList renders from ~1972 to ~296 per session.
Event → field mapping
Keep this in sync with handleDirectoryEvent in sync-context.tsx:
| Event type | Fields to clone |
|---|---|
session.created/updated/deleted |
session, permission, todo, part |
session.diff |
session_diff |
session.status |
session_status |
todo.updated |
todo |
message.updated |
message |
message.removed |
message, part |
message.part.updated/removed/delta |
part |
vcs.branch.updated |
(none — mutates draft.vcs directly) |
permission.asked/replied |
permission |
question.asked/replied/rejected |
question |
lsp.updated |
lsp |
Adding a new event type
- Add the case to the event reducer (
event-reducer.ts) - Add a corresponding case to the switch in
handleDirectoryEvent(sync-context.tsx) that clones only the fields your reducer writes to - If your event fires frequently (more than a few times per second), verify that unrelated components don't re-render — check with the stream perf counters
Selector hygiene
Select leaf values, not containers:
// WRONG — returns entire Map/object, new reference on any mutation
useDirectorySync((s) => s.permission)
// RIGHT — returns the value for one key, stable unless that key changes
useDirectorySync((s) => s.permission[sessionID] ?? EMPTY)
Same applies to useStreamingStore — select .get(key) not the Map itself.
Store splitting pattern
Why split
A single Zustand store with N properties means every subscriber's selector re-evaluates on every state change — even if the change is unrelated to what that subscriber reads. During streaming, sessionMemoryState updates ~60/sec. Before the split, all 68+ useSessionUIStore subscribers re-evaluated on each update. After splitting into focused stores, only useViewportStore subscribers (2-3 components) re-evaluate.
The optimization multiplies with targeted event cloning: fewer new references per event × fewer subscribers per store = dramatically less work per SSE frame.
The stores
| Store | Owns | When it changes |
|---|---|---|
session-ui-store.ts |
Session selection, draft lifecycle, abort, worktree, SDK actions | Session switch, draft open/close |
voice-store.ts |
Voice connection/activity state | Voice toggle |
input-store.ts |
Pending input text, synthetic parts, attached files | User typing, file attach, revert/fork |
selection-store.ts |
Per-session model/agent/variant choices | Model/agent picker |
viewport-store.ts |
Scroll anchors, session memory state, sync status | Streaming, scroll, session switch |
Rules for new UI state
- Never add to
session-ui-storeunless it's session selection, draft lifecycle, or abort state - Group by change frequency — state that changes during streaming (viewport, memory) must not live with state that changes on user action (selections, input)
- Group by subscriber set — if only 2 components read a value, it should be in a store that only those 2 components subscribe to
- Prefer a new store over growing an existing one if the new state has different subscribers or change frequency
- Cross-store reads use
.getState()— actions in one store that need to read another store calluseOtherStore.getState()(imperative, no subscription)
Anti-patterns
// WRONG — stuffing unrelated state into one store
const useEverythingStore = create(() => ({
voiceMode: "idle",
scrollAnchor: 0,
selectedModel: null,
pendingInput: "",
// 20 more fields...
}))
// RIGHT — separate stores by concern + change frequency
const useVoiceStore = create(() => ({ voiceMode: "idle" }))
const useViewportStore = create(() => ({ scrollAnchor: 0 }))
const useSelectionStore = create(() => ({ selectedModel: null }))
const useInputStore = create(() => ({ pendingInput: "" }))