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
+10 -7
View File
@@ -954,6 +954,16 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
)
: null
// Start the message fetch before publishing the selection. React flushes
// the discrete-event render in a microtask queued by `set`, so a fetch
// started after it would only leave the browser once that whole render
// finished. Started first, the request is on the wire while the render
// runs. Fire-and-forget: any transient failure is retried by the reactive
// path in ChatContainer.
if (id) {
void fetchMessagesForSession(id, resolvedDir)
}
// Set the directory together with the session id so chat hooks read the
// same child store that send/SSE events will update during startup races.
set({
@@ -970,13 +980,6 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
persistLastActiveSession(key, { sessionId: id, directory: rememberedDir })
}
// Kick off the message fetch on the same tick, before React commits the
// state change and fires ChatContainer.useEffect. The fetch is
// fire-and-forget — any transient failure gets retried by the reactive path.
if (id) {
void fetchMessagesForSession(id, resolvedDir)
}
try {
if (resolvedDir && directoryState.currentDirectory !== resolvedDir) {
directoryState.setDirectory(resolvedDir, { showOverlay: false })
+48 -14
View File
@@ -92,11 +92,24 @@ import {
// Context
// ---------------------------------------------------------------------------
/**
* The provider's current directory as a subscribable value instead of a
* context field. A hook that is handed an explicit directory reads a constant
* snapshot from it and therefore does not re-render when the current
* directory changes; a context read would re-render every consumer every
* sidebar row on each cross-project switch.
*/
type CurrentDirectorySource = {
get: () => string
subscribe: (notify: () => void) => () => void
}
type SyncRuntime = {
childStores: ChildStoreManager
messageLoader: SessionMessageLoader
runtimeKey: string
sdk: OpencodeClient
currentDirectory: CurrentDirectorySource
}
type SyncSystem = SyncRuntime & {
@@ -165,7 +178,7 @@ function useLiveSyncSelector<T>(
isEqual: (left: T, right: T) => boolean = Object.is,
subscribe?: (childStores: ChildStoreManager, notify: () => void) => () => void,
): T {
const { childStores } = useSyncSystem()
const { childStores } = useSyncRuntime()
const sourceRevisionRef = useRef(0)
const cacheRef = useRef<{
childStores: ChildStoreManager
@@ -2138,6 +2151,19 @@ export function SyncProvider(props: {
const routingIndex = routingIndexRef.current
const currentDirectoryRef = useRef(props.directory)
currentDirectoryRef.current = props.directory
// Written during render (above) so children rendering in the same pass read
// the new directory; subscribers are notified after commit.
const currentDirectoryListenersRef = useRef(new Set<() => void>())
const currentDirectorySource = useMemo<CurrentDirectorySource>(() => ({
get: () => currentDirectoryRef.current,
subscribe: (notify) => {
currentDirectoryListenersRef.current.add(notify)
return () => currentDirectoryListenersRef.current.delete(notify)
},
}), [])
React.useLayoutEffect(() => {
for (const notify of currentDirectoryListenersRef.current) notify()
}, [props.directory])
const lastStreamActivityAtRef = useRef(0)
const lastStatusPollAtByDirectoryRef = useRef(new Map<string, number>())
const lastFullResyncAtByDirectoryRef = useRef(new Map<string, number>())
@@ -2149,8 +2175,8 @@ export function SyncProvider(props: {
const pipelineDisconnectedBeforeFirstConnectRef = useRef(false)
const runtime = useMemo<SyncRuntime>(
() => ({ childStores, messageLoader, runtimeKey, sdk: props.sdk }),
[childStores, messageLoader, props.sdk, runtimeKey],
() => ({ childStores, messageLoader, runtimeKey, sdk: props.sdk, currentDirectory: currentDirectorySource }),
[childStores, currentDirectorySource, messageLoader, props.sdk, runtimeKey],
)
const system = useMemo<SyncSystem>(
() => ({ ...runtime, directory: props.directory }),
@@ -2700,20 +2726,25 @@ export function useDirectoryStore(
reason?: DirectoryBootstrapReason
},
): StoreApi<DirectoryStore> {
const system = useSyncSystem()
const dir = directory ?? system.directory
const store = system.childStores.ensureChild(dir, options)
const runtime = useSyncRuntime()
// With an explicit directory the snapshot is a constant, so a current-
// directory change does not re-render this consumer.
const dir = React.useSyncExternalStore(
runtime.currentDirectory.subscribe,
() => directory ?? runtime.currentDirectory.get(),
)
const store = runtime.childStores.ensureChild(dir, options)
useEffect(() => {
system.childStores.pin(dir)
return () => system.childStores.unpin(dir)
}, [dir, system.childStores])
runtime.childStores.pin(dir)
return () => runtime.childStores.unpin(dir)
}, [dir, runtime.childStores])
return store
}
export function useSessionMessageLoader(): SessionMessageLoader {
return useSyncSystem().messageLoader
return useSyncRuntime().messageLoader
}
export function useSessionMessageLoadState(sessionID: string, directory?: string): SessionMessageLoadState {
@@ -2866,7 +2897,10 @@ export function useSessionQuestions(sessionID: string, directory?: string) {
* streaming or session activity does not re-render rows.
*/
export function useSessionQuestionCount(scopes: readonly { directory: string; sessionIDs: readonly string[] }[]) {
const { childStores } = useSyncSystem()
// Runtime only: the current directory is not an input here, and reading the
// directory-bearing context would re-render every sidebar row that counts
// questions whenever the user switches projects.
const { childStores } = useSyncRuntime()
const scopedStores = React.useMemo(() => scopes.map((scope) => ({
sessionIDs: scope.sessionIDs,
store: childStores.ensureChild(scope.directory, { bootstrap: false }),
@@ -2989,7 +3023,7 @@ export function useParentSession(sessionID: string | null, directory?: string):
/** Get one session by id for a directory */
export function useSession(sessionID?: string | null, directory?: string) {
const { childStores } = useSyncSystem()
const { childStores } = useSyncRuntime()
const getSnapshot = useCallback(() => {
if (directory) {
const sessions = childStores.getChild(directory)?.getState().session
@@ -3018,7 +3052,7 @@ export function useSessionDirectory(sessionID?: string | null, directory?: strin
/** Get the SDK client */
export function useSyncSDK() {
return useSyncSystem().sdk
return useSyncRuntime().sdk
}
/** Get the current directory */
@@ -3028,7 +3062,7 @@ export function useSyncDirectory() {
/** Get the child store manager (for advanced operations) */
export function useChildStoreManager() {
return useSyncSystem().childStores
return useSyncRuntime().childStores
}
type SessionMessageRecord = { info: Message; parts: Part[] }