perf: isolate chat streaming renders and reduce sidebar render cost (#1672)

Reworks the chat and session-sidebar render paths to cut render cascades, memory
  churn, and UI jank on large sessions and big session trees. Behavior is preserved;
  the changes are about *when* and *how much* the UI re-renders.

  ## Chat streaming
  - Freeze the streaming message's parts in the bulk turn projection during streaming,
    and re-inject live parts only in an isolated tail leaf, so a ~60/sec delta stream
    no longer re-runs the whole-session projection or re-renders unrelated rows.
    session with referential reuse of unchanged turns.
  - Memoize message rows with field-aware comparators instead of reference equality.
  - Replace the manual child-session polling in the task tool with the live SSE
    stream + a one-shot load, removing a fetch/settle state machine.

  ## History loading & scroll
  - Load an initial page fast, then prepend one older page in the background so the
    scroll container has headroom and "load older on scroll-up" fires before the user
    hits the absolute top.
  - Compensate scroll synchronously (in a layout effect, before paint) for prepends —
    including background prepends that don't originate from a user scroll — so the
    viewport stays stable instead of judder-correcting on the next frame.

  ## Markdown rendering
  - Render markdown synchronously *styled* on first paint (paragraphs, lists, code
    cards, tables, inline code) instead of raw escaped text; the async pass then only
    upgrades syntax-highlight colors. Eliminates the flash of full-width raw text.
  - Load KaTeX CSS eagerly with the main bundle instead of inside the lazy markdown
    chunk, avoiding a late stylesheet injection on first render.

  ## Sidebar
  - Hoist per-row recursive tree walks out of row comparators into per-group
    precomputed sets/keys; batch live-session lookups into a single map; add a
    group-level memo boundary.
  - Isolate rename drafts so per-keystroke typing doesn't repaint the row tree.

  ## Sync layer
  - Add a staleness guard so a slow message fetch can't repopulate a session the user
    navigated away from.
  - Throw on fetch failure for authoritative loaders so a transient blip can't read as
    an empty server response.

  ## Cleanup
  - Remove dead code (unused hooks, params, duplicated inline types) surfaced while
    reworking the above.

  ## Known issue
  - A rare, purely cosmetic first-paint width flash can still appear on large sessions;
    it has no behavioral or data impact and is tracked for a follow-up runtime trace.
This commit is contained in:
bashrusakh
2026-06-18 00:43:16 +03:00
committed by GitHub
parent 077a766f94
commit 59ecd86b4b
47 changed files with 3168 additions and 1829 deletions
@@ -0,0 +1,120 @@
import type { SessionNode } from './types';
/**
* Per-row render extras precomputed once per group render and threaded down to
* each `SessionNodeItem`. Hoisting these out of the row `React.memo` comparator
* turns an O(rows × subtree-depth) walk into per-row `Set.has`/string compares.
*
* The child variant intentionally omits `childRenderExtrasFor` — the resolver is
* shared from the group and re-passed, so it does not need to recurse through
* each child's extras object.
*/
export type SessionNodeChildRenderExtras = {
subtreeContainsActive: Set<string>;
subtreeContainsEditing: Set<string>;
menuOpenSessionId: string | null;
nodeStructureKey: string;
};
export type SessionNodeRenderExtras<TNode = SessionNode> = SessionNodeChildRenderExtras & {
childRenderExtrasFor?: (child: TNode) => SessionNodeChildRenderExtras;
};
/**
* Walk `nodes` and add `node.session.id` to `result` for every node
* whose subtree contains `targetId`. This is used to precompute, once
* per SessionGroupSection render, which rows need to update when
* `currentSessionId` or `editingId` changes. With M visible rows, this
* turns an O(M × subtree-depth) walk inside `SessionNodeItem.areEqual`
* into a single O(M) `Set.has` per row.
*/
export const collectSubtreeContainingId = (
nodes: SessionNode[],
targetId: string | null,
result: Set<string>,
): void => {
if (!targetId) return;
const visit = (node: SessionNode): boolean => {
let containsTarget = node.session.id === targetId;
for (const child of node.children) {
containsTarget = visit(child) || containsTarget;
}
if (containsTarget) {
result.add(node.session.id);
}
return containsTarget;
};
for (const node of nodes) {
visit(node);
}
};
export const nodeContainsSessionId = (node: SessionNode, sessionId: string | null): boolean => {
if (!sessionId) {
return false;
}
if (node.session.id === sessionId) {
return true;
}
for (const child of node.children) {
if (nodeContainsSessionId(child, sessionId)) {
return true;
}
}
return false;
};
/**
* Build a structural key for `node` that encodes the IDs of all
* descendants. Used by `SessionNodeItem.areEqual` so a reference-only
* rebuild of the tree (which happens on every `buildGroupedSessions`
* pass) can be detected with a single string compare instead of a
* recursive walk per row.
*/
export const computeNodeStructureKey = (node: SessionNode): string => {
if (node.children.length === 0) {
return '';
}
const childKeys = node.children.map((child) => {
if (child.children.length === 0) {
return child.session.id;
}
return `${child.session.id}:${computeNodeStructureKey(child)}`;
});
return childKeys.join('|');
};
/**
* Resolve the session id whose sidebar menu is open, or null if no
* menu is open. Only one row can have its menu open at a time.
*/
export const resolveMenuOpenSessionId = (
nodes: SessionNode[],
menuKey: string | null,
renderContext: 'project' | 'recent',
archivedBucket: boolean,
): string | null => {
if (!menuKey) return null;
const bucketTag = archivedBucket ? 'archived' : 'active';
let result: string | null = null;
const visit = (node: SessionNode): boolean => {
const nodeMenuKey = `${renderContext}:${bucketTag}:${node.session.id}`;
if (nodeMenuKey === menuKey) {
result = node.session.id;
return true;
}
for (const child of node.children) {
if (visit(child)) return true;
}
return false;
};
nodes.forEach((node) => visit(node));
return result;
};