218 lines
9.0 KiB
Markdown
218 lines
9.0 KiB
Markdown
# 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:
|
|||
|
|
|
|||
|
|
1. **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()`
|
|||
|
|
|
|||
|
|
2. **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
|
|||
|
|
|
|||
|
|
These two scopes are intentionally different.
|
|||
|
|
|
|||
|
|
### 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 sidebar/retention global session lists
|
|||
|
|
|
|||
|
|
## 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 view**.
|
|||
|
|
|
|||
|
|
Current consumers:
|
|||
|
|
|
|||
|
|
- `SessionSidebar.tsx`
|
|||
|
|
- `useSessionAutoCleanup.ts`
|
|||
|
|
|
|||
|
|
### Mutation responsibility
|
|||
|
|
|
|||
|
|
`useGlobalSessionsStore` is not maintained by SSE directly. It is kept correct by:
|
|||
|
|
|
|||
|
|
1. shared global fetch/reconciliation via `loadSessions()` / `refreshGlobalSessions()`
|
|||
|
|
2. direct mutation from session actions after successful SDK calls:
|
|||
|
|
- create
|
|||
|
|
- title update
|
|||
|
|
- share
|
|||
|
|
- unshare
|
|||
|
|
- archive
|
|||
|
|
- delete
|
|||
|
|
- retention cleanup batch archive/delete
|
|||
|
|
|
|||
|
|
This keeps sidebar/retention UI responsive without requiring a refetch after every change.
|
|||
|
|
|
|||
|
|
## 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:
|
|||
|
|
|
|||
|
|
1. If an action mutates session list membership or visible session metadata, update `useGlobalSessionsStore` there.
|
|||
|
|
2. If an action targets a session by ID, resolve the **session's own directory**. Do not assume the current directory is correct.
|
|||
|
|
3. `session-ui-store.ts` should delegate to `session-actions.ts` for 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.
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// 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
|
|||
|
|
|
|||
|
|
1. Add the case to the event reducer (`event-reducer.ts`)
|
|||
|
|
2. Add a corresponding case to the switch in `handleDirectoryEvent` (`sync-context.tsx`) that clones **only** the fields your reducer writes to
|
|||
|
|
3. 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:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// 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
|
|||
|
|
|
|||
|
|
1. **Never add to `session-ui-store`** unless it's session selection, draft lifecycle, or abort state
|
|||
|
|
2. **Group by change frequency** — state that changes during streaming (viewport, memory) must not live with state that changes on user action (selections, input)
|
|||
|
|
3. **Group by subscriber set** — if only 2 components read a value, it should be in a store that only those 2 components subscribe to
|
|||
|
|
4. **Prefer a new store over growing an existing one** if the new state has different subscribers or change frequency
|
|||
|
|
5. **Cross-store reads use `.getState()`** — actions in one store that need to read another store call `useOtherStore.getState()` (imperative, no subscription)
|
|||
|
|
|
|||
|
|
### Anti-patterns
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// 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: "" }))
|
|||
|
|
```
|