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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user