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
@@ -51,6 +51,7 @@ import {
import { fileReferenceExists } from './fileReferenceStat';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
import { detachedMarkdownDomCache, type DetachedMarkdownDomKey } from './markdown/detachedMarkdownDomCache';
import { TimelineRevealGateContext } from './timelineRevealGate';
import { getRuntimeKey } from '@/lib/runtime-switch';
const useCurrentMermaidTheme = () => {
@@ -692,6 +693,30 @@ const useMermaidInlineInteractions = ({
const MERMAID_RENDER_CACHE = new Map<string, MermaidRender>();
const MERMAID_RENDER_CACHE_MAX = 100;
const MARKDOWN_DECORATION_ID_ATTR = 'data-md-decoration-id';
// True when the container already holds exactly these settled blocks with the
// current decoration. The first paint of a remounted message is served from
// the block cache; when that paint is already final, the async render would
// only parse, highlight, sanitize, and morph the same HTML into place again.
const domMatchesRenderedBlocks = (
target: HTMLElement,
blocks: ReadonlyArray<{ id: string }>,
decorationId: string,
): boolean => {
const children = target.children;
if (children.length !== blocks.length) return false;
for (let index = 0; index < blocks.length; index += 1) {
const child = children[index];
if (
!child
|| child.getAttribute('data-md-id') !== blocks[index]?.id
|| child.getAttribute(MARKDOWN_DECORATION_ID_ATTR) !== decorationId
) {
return false;
}
}
return true;
};
const MARKDOWN_DECORATION_IDS = new WeakMap<DecorateContext, string>();
let nextMarkdownDecorationId = 0;
const MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS = 200_000;
@@ -804,6 +829,16 @@ const useMorphdomMarkdown = ({
const mermaidViewerRef = React.useRef<ReturnType<typeof createMermaidViewerRegistry> | null>(null);
const renderRevisionRef = React.useRef(0);
// A provisional first paint (blocks not in the settled cache) holds the
// timeline reveal until the async render lands, so the session opens with
// final code highlighting instead of a visible restyle.
const revealGate = React.useContext(TimelineRevealGateContext);
const releaseRevealHoldRef = React.useRef<(() => void) | null>(null);
const releaseRevealHold = React.useCallback(() => {
releaseRevealHoldRef.current?.();
releaseRevealHoldRef.current = null;
}, []);
React.useEffect(() => releaseRevealHold, [releaseRevealHold]);
// Only DOM that was actually restored or completed by the async pipeline is
// eligible for capture. A fallback from an earlier content revision is not.
const mountedDomRef = React.useRef<{
@@ -909,6 +944,9 @@ const useMorphdomMarkdown = ({
}
if (hasMermaidBlock) refreshMermaidViewers();
} else {
if (!streaming && !releaseRevealHoldRef.current) {
releaseRevealHoldRef.current = revealGate?.hold() ?? null;
}
const block = document.createElement('div');
block.setAttribute('data-md-block', '');
block.style.display = 'contents';
@@ -939,6 +977,18 @@ const useMorphdomMarkdown = ({
const renderRevision = renderRevisionRef.current;
const decorationId = getMarkdownDecorationId(ctx);
if (!streaming) {
const cachedBlocks = getCachedMarkdownBlocks(text, imageMode);
if (cachedBlocks && domMatchesRenderedBlocks(target, cachedBlocks, decorationId)) {
mountedDomRef.current = domCacheKey
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
: null;
streamPerfCount('ui.markdown_renderer.settled_paint.reused');
releaseRevealHold();
return;
}
}
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
if (!active || renderRevisionRef.current !== renderRevision) return;
const existing = Array.from(target.children) as HTMLElement[];
@@ -1028,12 +1078,13 @@ const useMorphdomMarkdown = ({
mountedDomRef.current = domCacheKey
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
: null;
releaseRevealHold();
});
return () => {
active = false;
};
}, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, streaming, text]);
}, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, releaseRevealHold, streaming, text]);
React.useEffect(() => {
const container = containerRef.current;