perf(chat): make session switching feel instant

Switching sessions ran as one synchronous commit: sidebar highlight, URL,
a full timeline remount with markdown re-parse, and around nine requests,
so nothing changed on screen for 150-250ms after the click.

- ChatContainer swaps the timeline on a deferred copy of the selection, so
  the active row, URL, and tab commit first and the timeline renders behind
  them; selection policy keeps reading the live store value.
- The message fetch starts before the selection is published.
- Sidebar rows stop re-rendering on a project switch: directory-scoped sync
  hooks read the runtime context and a subscribable current-directory source
  instead of the directory-bearing context; the grouping builder reads git
  branches through a ref and section caches key the branches they use;
  descendant ids are keyed by content. Rows per switch went from 73 to 8.
- Markdown skips the async re-render when the settled cached blocks are
  already painted, and mounts synchronously once its lazy module is loaded;
  the module is preloaded at boot.
- A timeline reveal gate holds a freshly opened session at opacity 0 while
  any provisional markdown paint catches up (250ms cap), then fades the whole
  timeline in once, so text, tools, and recap appear together.
- Switch fan-out trimmed: knowledge summary deduped, MCP status refreshed only
  when stale, non-repo directories cached by the git repo check, OpenChamber
  defaults cached briefly, agent memory reused for the same project, goal
  text cached, PWA manifest rebuilt after the switch settles.
- Header tabs snap into the active state and keep the title at the same
  height in both states.
- Prefetch on row press; composer focus moved off the commit.

`bun run profile:switch` records ack/content latency, longest task, and
requests per switch, cold and warm, and compares runs against a baseline.
Measured warm switch: ack 228ms to about 40-60ms, content 228ms to about
100-120ms.
This commit is contained in:
Bohdan Triapitsyn
2026-08-29 17:01:15 +03:00
parent 123c14260a
commit edfc9779cf
32 changed files with 947 additions and 118 deletions
+43
View File
@@ -413,6 +413,49 @@ The global stream can omit a directory for a session-addressed event. Resolve it
## Selector hygiene
### Runtime context versus directory context
`SyncProvider` publishes two contexts. `SyncRuntimeContext` (`useSyncRuntime()`)
holds the child-store manager, message loader, SDK, runtime key, and a
subscribable `currentDirectory` source; its value changes only on runtime
reconfiguration. `SyncContext` (`useSyncSystem()` / `useSync()`) adds the
current directory string, so every consumer re-renders on each directory
switch.
A hook that takes an explicit directory, or needs only runtime fields, must
read `useSyncRuntime()`. `useDirectoryStore(directory)` reads the current
directory through `runtime.currentDirectory` with `useSyncExternalStore`, so a
consumer that passes its own directory gets a constant snapshot and is not
re-rendered by a cross-project switch. This is what keeps sidebar rows
(permissions, question counts, session lookups) out of the switch commit: a
row must not pay for the chat changing directory.
### Session switch commit
The sidebar click publishes `currentSessionId`/`currentSessionDirectory`
synchronously, and the message fetch starts before that publication so the
request is on the wire while React renders. `ChatContainer` consumes a
`useDeferredValue` copy of the selection: the first commit paints the cheap
reactions (active row, URL, tabs) and the timeline for the new session renders
in a transition behind it. Selection *policy* inside `ChatContainer` (auto-
opening a draft when nothing is selected) reads the live store value, because
the deferred one still names the previous session for one commit.
The timeline's first paint for a session is atomic. `ChatContainer` owns a
`TimelineRevealGate` per session key (`components/chat/timelineRevealGate.ts`):
a markdown renderer whose first paint is provisional (blocks not yet in the
settled cache, so code is unhighlighted) takes a hold in its layout effect,
and the timeline root stays at opacity 0 until every hold releases, capped at
250ms, then fades in once as a whole. A warm switch takes no holds and reveals
in the same frame. The gate stops accepting holds after the opening commit so
rows mounting during scroll never hide the timeline. Once the lazy markdown
module has loaded, `MarkdownRenderer` mounts it synchronously instead of
through `Suspense`: a suspended boundary shows its fallback for a tick and
React then throttles later-resolving boundaries by ~300ms, which staggered
user and assistant text on a cold open.
`bun run profile:switch` measures both moments; see `scripts/perf/DOCUMENTATION.md`.
Select leaf values, not containers:
```typescript