From a694adfb34b86689c7dcd9f3da00ee38c52b75fe Mon Sep 17 00:00:00 2001 From: Bowen Liang Date: Mon, 19 Oct 2026 07:38:56 +0800 Subject: [PATCH] perf(context): cut context-tab render, hover and DNA costs (#167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(context): cut context-tab render, hover and DNA costs P0 — interaction/caching: - isolate pointer hovers: chart-local hover stays immediate, cross-card previews trail (140ms; 220ms in DNA mode) so sweeps cannot stack full-view renders; - cache pure projections (assemble, DNA/file derivations) across hover/remount; - remove forced layout from the flow/timing read paths, gate the hidden sidebar copy, memoize picker options and tool tallies. P1 — windowing/lazy rendering: - window trend bars, file-activity rows, timing-strip segments (dense gradient) and the browser DNA feed; off-screen rows use content-visibility; - lazy-mount the browser card and the below-fold event/file row behind height-matched skeletons, mounted on an idle slice / near the viewport; - window DNA bands on long total-mode logs, tighten the DNA window, sample per-bar gradient stops, and keep DNA+delta eager so its churn axis stays exact; - force-mount + reveal for stats file-pill jumps into the lazy row. Rendering-only by construction: axes, tooltips, ordering, DNA hit-testing and every readout still come from the full model; scrolling only changes which rows are in the DOM. Measured on a 987-step session: mount 986-1251ms -> ~510ms, first paint 480-592ms -> ~80ms, zero long tasks; tab DOM 15,929 -> 3,226 (below-fold mounted ~4.7k); hover 30 -> 0 long tasks; DNA toggle 875 -> ~230ms. Tests: new window/lazy/DNA/cache specs; lint, typecheck and 138-file 100% per-file coverage green. * fix(context): second-review follow-ups - keep DNA bands fresh per header epoch, and force eager DNA+delta on long logs so per-key churn axes stay exact; - correct windowed spacer heights for the container flex gap: the file list and the DNA feed now reproduce the full scroll height exactly; - re-translate memoized picker/tooltip labels when the locale changes; - re-observe lazy mounts across detach/reattach (a collapsed sidebar copy) via a callback ref, keep forced mounts for stats jumps, and clear the chart's local hover/DNA state on mouse leave; - shrink the window for a mount that already has DNA on, and add the async subagent count to the flow-measurement deps; - repair the skeleton background rule and restore the accidentally globalized .lc-heat-ctl .lc-card-sub selector. --- src/client/components/browser.tsx | 155 +++--- src/client/components/browserHelpers.ts | 38 ++ src/client/components/contextView.tsx | 233 +++++++-- src/client/components/derivedCache.ts | 47 ++ src/client/components/fileCard.tsx | 45 +- src/client/components/stackedBar.tsx | 8 +- src/client/components/statsContext.tsx | 12 +- src/client/components/statsTiming.tsx | 5 +- src/client/components/timingStrip.tsx | 130 ++++- src/client/components/trendChart.tsx | 471 +++++++++--------- src/client/components/trendDnaBars.tsx | 187 +++++++ src/client/components/trendDurationCurve.tsx | 27 + src/client/components/trendWindow.ts | 45 ++ src/client/components/useLazyMount.ts | 49 ++ src/client/components/useRowWindow.ts | 68 +++ src/client/styles/base.css | 1 + src/client/styles/events.css | 2 +- src/client/styles/fileCard.css | 3 +- src/client/styles/stats.css | 4 + .../components/browserDnaWindow.spec.ts | 89 ++++ .../contextView.fileActivity.spec.ts | 36 +- .../components/contextViewLazyDna.spec.ts | 70 +++ .../components/contextViewLazyMount.spec.ts | 73 +++ tests/client/components/derivedCache.spec.ts | 38 ++ .../client/components/fileCardWindow.spec.ts | 128 +++++ tests/client/components/stackedBar.spec.ts | 31 ++ tests/client/components/timingStrip.spec.ts | 62 ++- tests/client/components/trendChartHarness.ts | 4 +- .../components/trendChartLazyDna.spec.ts | 112 +++++ .../components/trendChartWindow.spec.ts | 104 ++++ tests/client/components/useLazyMount.spec.ts | 133 +++++ tests/client/components/useRowWindow.spec.ts | 137 +++++ 32 files changed, 2142 insertions(+), 405 deletions(-) create mode 100644 src/client/components/browserHelpers.ts create mode 100644 src/client/components/derivedCache.ts create mode 100644 src/client/components/trendDnaBars.tsx create mode 100644 src/client/components/trendDurationCurve.tsx create mode 100644 src/client/components/trendWindow.ts create mode 100644 src/client/components/useLazyMount.ts create mode 100644 src/client/components/useRowWindow.ts create mode 100644 tests/client/components/browserDnaWindow.spec.ts create mode 100644 tests/client/components/contextViewLazyDna.spec.ts create mode 100644 tests/client/components/contextViewLazyMount.spec.ts create mode 100644 tests/client/components/derivedCache.spec.ts create mode 100644 tests/client/components/fileCardWindow.spec.ts create mode 100644 tests/client/components/trendChartLazyDna.spec.ts create mode 100644 tests/client/components/trendChartWindow.spec.ts create mode 100644 tests/client/components/useLazyMount.spec.ts create mode 100644 tests/client/components/useRowWindow.spec.ts diff --git a/src/client/components/browser.tsx b/src/client/components/browser.tsx index 1700153..af37467 100644 --- a/src/client/components/browser.tsx +++ b/src/client/components/browser.tsx @@ -1,9 +1,9 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from 'react' +import { assembleCached } from './derivedCache' import { UNKNOWN_TOOL_SOURCE, type Category, type ContextHeaders, type ContextTimeline, type HeaderRecord, type HeaderTool, type RequestRecord, type SurfaceNode, type SystemPromptNode } from '../../shared/types' -import { assemble } from '../assemble' -import type { Assembled } from '../assemble' import { CATS, CAT_COLOR, partsOf } from '../categories' import { dnaBaseLabel, dnaOf } from '../dna' +import { useRowWindow } from './useRowWindow' import type { DnaItem } from '../dna' import type { ContentFetcher, ConversationNodeLike, HeaderFetcher } from '../services' import type { ContextSettings, DefaultDeltaBase, DefaultToolSort } from '../settings' @@ -23,10 +23,16 @@ import { TextSection, schemaTextOf, RowToolbar, ToolSchema, toolErrOf } from './ import { revealRowInCard } from '../revealScroll' import { NodeContent, byCatOf, nodeNameOf, msgKindsOf, reasoningTextOf, ROW_KINDS } from './browserNodes' import type { RowKind } from './browserNodes' +import { DNA_MIN_BAND, countOf, lastOfTurn, stepStampOf } from './browserHelpers' +import type { CatFocus } from './browserHelpers' +export type { CatFocus } from './browserHelpers' +export { stepStampOf } from './browserHelpers' import { MapIcon, makeBrowserTreemap } from './browserTreemap' import type { MapBlock } from '../treemap' export interface ContextBrowserProps { + /** Active UI locale; the picker labels are translated eagerly, so a switch must rebuild them. */ + locale?: string data: ContextTimeline headers: ContextHeaders | null /** Resolved by the caller so the `useChat` seat's hook order lives in exactly one place. */ @@ -58,41 +64,6 @@ export interface ContextBrowserProps { } -export interface CatFocus { - cat: string - kind?: RowKind -} - -function countOf(asm: Assembled, byCat: Partial>, c: string): number { - if (c === 'system') return asm.system !== null ? 1 : 0 - if (c === 'tools') return asm.header !== null ? asm.header.tools.length : 0 - return byCat[c as Category]?.length ?? 0 -} - -function lastOfTurn(requests: RequestRecord[], turn: number): RequestRecord | null { - for (let i = requests.length - 1; i >= 0; i--) if ((requests[i].turn ?? 0) === turn) return requests[i] - return null -} - -/** The turn/step marking where a surface item entered the context: the FIRST request logged after the item's seq is - * the first that carried it (assemble's inclusion rule is `n.seq < R.seq`); no following request stamps null. */ -export function stepStampOf(requests: RequestRecord[]): (itemSeq: number) => { turn: number; step: number } | null { - // Sort a copy: the wire arrives in append order, but the derivation must not depend on it (restored rows can re-order). - const sorted = requests.slice().sort((a, b) => a.seq - b.seq) - return (itemSeq) => { - for (const r of sorted) { - if (r.seq > itemSeq) { - return typeof r.turn === 'number' && typeof r.step === 'number' ? { turn: r.turn, step: r.step } : null - } - } - return null - } -} - -/** Bands keep at least this share of the occupied region, so a tiny item stays a hoverable/clickable filament - * instead of a sub-pixel sliver; tooltips still report true shares. */ -const DNA_MIN_BAND = 0.35 - export function makeContextBrowser( kit: ViewKit, StackedBar: (props: StackedBarProps) => ReactElement, @@ -171,6 +142,11 @@ export function makeContextBrowser( setOpenElem(null) }, [pinSeq, onOpenCat]) const rootRef = useRef(null) + const scrollRef = useRef(null) + // The DNA flat feed can carry thousands of sub-row-sized items; the rows region is a fixed-pitch scroller, so + // only the visible window is rendered once the card is wide enough for single-line rows. + const [flatWide, setFlatWide] = useState(false) + const [flatReady, setFlatReady] = useState(false) const focusScrollRef = useRef(false) const nodeFocus = props.nodeFocus useEffect(() => { @@ -206,6 +182,15 @@ export function makeContextBrowser( const requests = data.requests const stepsOf = useMemo(() => turnStepsOf(requests), [requests]) + // One + )), + [requests, stepsOf, t, props.locale], + ) const stampOf = useMemo(() => stepStampOf(requests), [requests]) // The live tail — items no logged request carries yet — rides the NEXT request, following the harness's step loop // (`core/agent-loop`'s agent.ts): a newest user message opens a new turn, a text-only final reply ends the turn, @@ -246,7 +231,9 @@ export function makeContextBrowser( const linkKey = linked && props.hoverKey !== null && props.hoverKey !== 'free' ? props.hoverKey : null - const view = assemble(data, headers, seq) + // assemble() is a pure walk over the served projection; memoize it per (projection, step) so a hover + // preview only pays for the steps it actually shows. + const view = useMemo(() => assembleCached(data, headers, seq), [data, headers, seq]) /** The tools group's element keys: the group key itself or any exact tool schema key. */ const toolsElemOpen = (): boolean => openElem === 'tools' || (openElem !== null && openElem.startsWith('tool:')) @@ -292,7 +279,10 @@ export function makeContextBrowser( ? deltaBase === 'step' ? lastReq : lastReq === null ? null : turnBaseOf(lastReq) : deltaBase === 'turn' ? turnBaseOf(req) : prevRecordOf(req) const zeroBase = refReq === null && lastReq !== null - const prevView = refReq !== null ? assemble(data, headers, refReq.seq) : null + const prevView = useMemo( + () => (refReq !== null ? assembleCached(data, headers, refReq.seq) : null), + [data, headers, refReq], + ) const prevByCat = prevView !== null ? byCatOf(prevView) : null const byCat = byCatOf(view) @@ -766,20 +756,38 @@ export function makeContextBrowser( ) - const flatRows = (): ReactElement => { + /* v8 ignore start -- jsdom has no layout/ResizeObserver: the wide gate and the first-paint skeleton are live-only. */ + useEffect(() => { + if (!dna) { + setFlatReady(false) + return + } + const el = scrollRef.current + if (el === null) return + const sync = (): void => { setFlatWide(el.clientWidth >= 380) } + sync() + setFlatReady(true) + if (typeof ResizeObserver !== 'function') return + const observer = new ResizeObserver(sync) + observer.observe(el) + return () => { observer.disconnect() } + }, [dna]) + /* v8 ignore stop */ + + interface FlatEntry { + r: NodeRow + pos: number + } + /** Only the visible feed slice becomes React elements: a long session carries over a thousand rows, and + * building them all just to render ~30 was the DNA toggle's dominant cost. */ + const flatRows = (): { entries: FlatEntry[]; tail: ReactElement[]; empty: boolean } => { const q = rowQuery.trim().toLowerCase() - const rows: ReactElement[] = [] // Positions count ascending in reading order; reversing the array must not renumber them. - const flow = view.nodes.map((n, i) => ({ r: nodeRowOf(n), pos: i + 1 })).reverse() - for (const { r, pos } of flow) { + const entries: FlatEntry[] = [] + for (let i = view.nodes.length - 1; i >= 0; i--) { + const r = nodeRowOf(view.nodes[i]) if (q !== '' && !rowMatches(r, q)) continue - rows.push(messageRow(r, ( - <> - {catChip(r.n.cat)} - #{fmt(pos)} - {rowTagNode(r.tag, r.tagNames)} - - ), (over) => { setRowDnaKey(over ? 'n' + String(r.n.seq) : null) })) + entries.push({ r, pos: i + 1 }) } // An empty q matches everything (String.includes('') is true), so the lens needs no short-circuit arms. // A header-less surface (no epoch served) renders message bands only, so its feed carries no header rows. @@ -788,15 +796,16 @@ export function makeContextBrowser( const sys = view.system const sysText = sys !== null ? headerContent.get(sys.seq)?.system ?? '' : '' const sysHit = sys !== null && (systemPreviewOf(sys).toLowerCase().includes(q) || sysText.toLowerCase().includes(q)) - if (view.header !== null && toolsHit) rows.push(toolsGroupRow(view.header)) - if (sys !== null && sysHit) rows.push(systemRow(sys)) - return ( -
- {rows} - {rows.length === 0 ?
{t('browser.rowNoMatch')}
: null} -
- ) + const tail: ReactElement[] = [] + if (view.header !== null && toolsHit) tail.push(toolsGroupRow(view.header)) + if (sys !== null && sysHit) tail.push(systemRow(sys)) + return { entries, tail, empty: entries.length === 0 && tail.length === 0 } } + const flatRowsModel = dna && flatReady ? flatRows() : null + // 28px row + 2px flat gap: the pad formula reproduces the full feed height while only the viewport renders. + const flatRange = useRowWindow(scrollRef, flatRowsModel?.entries.length ?? 0, 30, dna && flatReady && flatWide && openElem === null) + // A non-DNA render never shows this model; the local keeps the JSX branch-free (the DNA branch owns the rows). + const flat = flatRowsModel ?? { entries: [] as FlatEntry[], tail: [] as ReactElement[], empty: true } return (
@@ -834,11 +843,7 @@ export function makeContextBrowser( onChange={(e) => { pick(e.target.value) }} > - {requests.slice().reverse().map(r => ( - - ))} + {pickOptions}
@@ -913,9 +918,29 @@ export function makeContextBrowser( ) : null} -
+
{dna - ? flatRows() + ? (!flatReady + /* v8 ignore next 2 -- first paint only; jsdom flushes effect work before assertions. */ + ?