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
+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[] }