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
+25
View File
@@ -53,6 +53,8 @@ type RefreshOptions = {
silent?: boolean;
};
const ensureFreshInFlight = new Map<string, Promise<void>>();
type TestConnectionResult = {
status?: McpStatus;
error?: string;
@@ -64,11 +66,19 @@ interface McpStore {
diagnosticsByDirectory: Record<string, McpRuntimeDiagnosticMap>;
loadingKeys: Record<string, boolean>;
lastErrorKeys: Record<string, string | null>;
/** When each directory's status was last fetched successfully. */
refreshedAtKeys: Record<string, number>;
getStatusForDirectory: (directory?: string | null) => McpStatusMap;
getDiagnosticForDirectory: (directory?: string | null) => McpRuntimeDiagnosticMap;
getErrorForDirectory: (directory?: string | null) => string | null;
refresh: (options?: RefreshOptions) => Promise<void>;
/**
* Refresh only when the directory has no status yet or the last successful
* fetch is older than `maxAgeMs`. Mount-time consumers use this so a panel
* that remounts on every session switch does not refetch on every switch.
*/
ensureFresh: (options: RefreshOptions & { maxAgeMs: number }) => Promise<void>;
connect: (name: string, directory?: string | null) => Promise<void>;
disconnect: (name: string, directory?: string | null) => Promise<void>;
startAuth: (name: string, directory?: string | null) => Promise<string>;
@@ -89,6 +99,7 @@ export const useMcpStore = create<McpStore>()(
diagnosticsByDirectory: {},
loadingKeys: {},
lastErrorKeys: {},
refreshedAtKeys: {},
getStatusForDirectory: (directory) => {
const key = toKey(directory ?? useDirectoryStore.getState().currentDirectory);
@@ -131,6 +142,7 @@ export const useMcpStore = create<McpStore>()(
},
loadingKeys: { ...state.loadingKeys, [key]: false },
lastErrorKeys: { ...state.lastErrorKeys, [key]: null },
refreshedAtKeys: { ...state.refreshedAtKeys, [key]: Date.now() },
}));
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load MCP status';
@@ -141,6 +153,19 @@ export const useMcpStore = create<McpStore>()(
}
},
ensureFresh: async ({ maxAgeMs, ...options }) => {
const key = toKey(normalizeDirectory(options.directory ?? useDirectoryStore.getState().currentDirectory));
const refreshedAt = get().refreshedAtKeys[key];
if (refreshedAt !== undefined && Date.now() - refreshedAt < maxAgeMs) return;
const inFlight = ensureFreshInFlight.get(key);
if (inFlight) return inFlight;
const request = get().refresh(options).finally(() => {
ensureFreshInFlight.delete(key);
});
ensureFreshInFlight.set(key, request);
return request;
},
connect: async (name, directory) => {
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
const key = toKey(normalized);