Files
Bowen Liang a694adfb34 perf(context): cut context-tab render, hover and DNA costs (#167)
* 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.
2026-10-19 07:38:56 +08:00

135 lines
4.7 KiB
TypeScript

/**
* jsdom reports zero layout metrics, so beforeAll overrides them (scrollWidth follows the bar count, clientWidth is
* test-controlled, the scrollLeft setter dispatches a real scroll event in a microtask) — the overflow/scroll-anchor
* logic runs FOR REAL — and afterAll restores the originals.
*/
import assert from 'node:assert/strict'
import { act } from 'react'
import { afterAll, beforeAll } from 'vitest'
import { makeTrendChart, type TrendChartProps } from '../../../src/client/components/trendChart'
import type { RequestRecord } from '../../../src/shared/types'
import { flush, makeKit, queryAll } from '../helpers/kit'
const kit = makeKit()
const TrendChart = makeTrendChart(kit)
const CHART_H = 112
const BAR_CELL = 16 // BAR_W 14 + BAR_GAP 2
/** Module-level default client width; tests may retarget it (with try/finally) before mounting. */
const viewport = { clientW: 400 }
type LayoutEl = HTMLElement & { __clientW?: number; __scrollL?: number; __scrollW?: number }
let saved: [string, PropertyDescriptor | undefined][] = []
beforeAll(() => {
saved = (['scrollWidth', 'clientWidth', 'scrollLeft'] as const)
.map((name): [string, PropertyDescriptor | undefined] => [name, Object.getOwnPropertyDescriptor(HTMLElement.prototype, name)])
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
configurable: true,
get(this: LayoutEl): number {
if (this.classList && this.classList.contains('lc-chart-scroll')) {
const total = Number(this.querySelector('.lc-chart')?.getAttribute('data-lc-bars') ?? '')
const columns = Number.isFinite(total) && total > 0 ? total : this.querySelectorAll('.lc-bar').length
return Math.max(this.clientWidth, columns * BAR_CELL)
}
return this.__scrollW ?? 0
},
})
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
get(this: LayoutEl): number { return this.__clientW ?? viewport.clientW },
})
Object.defineProperty(HTMLElement.prototype, 'scrollLeft', {
configurable: true,
get(this: LayoutEl): number { return this.__scrollL ?? 0 },
set(this: LayoutEl, v: number) {
this.__scrollL = Math.max(0, Math.min(v, Math.max(0, this.scrollWidth - this.clientWidth)))
const el = this
queueMicrotask(() => { if (el.isConnected) el.dispatchEvent(new Event('scroll')) })
},
})
})
afterAll(() => {
for (const [name, desc] of saved) {
if (desc === undefined) delete (HTMLElement.prototype as unknown as Record<string, unknown>)[name]
else Object.defineProperty(HTMLElement.prototype, name, desc)
}
})
function req(seq: number, over: Partial<RequestRecord> = {}): RequestRecord {
return {
time: 1700000000000 + seq * 60000, seq, turn: 1, step: seq - 1,
system: 100, tools: 50, user: 30, inject: 20, assistant: 40, tool: 60, total: 300,
...over,
}
}
interface Spies {
select: (number | null)[]
hover: (number | null)[]
hoverTurn: (number | null)[]
pickTurn: number[]
focusHandled: number
}
function makeSpies(): { spies: Spies; handlers: Pick<TrendChartProps, 'onSelect' | 'onHover' | 'onHoverTurn' | 'onPickTurn' | 'onFocusTurnHandled'> } {
const spies: Spies = { select: [], hover: [], hoverTurn: [], pickTurn: [], focusHandled: 0 }
return {
spies,
handlers: {
onSelect: (s) => { spies.select.push(s) },
onHover: (s) => { spies.hover.push(s) },
onHoverTurn: (t) => { spies.hoverTurn.push(t) },
onPickTurn: (t) => { spies.pickTurn.push(t) },
onFocusTurnHandled: () => { spies.focusHandled++ },
},
}
}
function propsOf(requests: RequestRecord[], over: Partial<TrendChartProps> = {}): TrendChartProps {
const { handlers } = makeSpies()
return {
requests,
markers: requests.map(() => undefined),
selectedSeq: null,
hoveredSeq: null,
activeTurn: null,
granularity: 'step',
mode: 'total',
focusTurn: null,
hoverCat: null,
...handlers,
...over,
}
}
function bars(container: HTMLElement): HTMLElement[] {
return queryAll(container, '.lc-bar')
}
/** jsdom/cssstyle may keep hex or normalize to rgb(); accept the exact color either way. */
function assertColor(actual: string, hex: string): void {
const n = parseInt(hex.slice(1), 16)
const rgb = `rgb(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255})`
assert.ok(actual === hex || actual === rgb, `expected ${actual} to be ${hex}`)
}
async function scrollTo(el: LayoutEl, v: number): Promise<void> {
await act(async () => { el.scrollLeft = v })
await flush()
}
async function scrollEvent(el: Element): Promise<void> {
await act(async () => { el.dispatchEvent(new Event('scroll')) })
await flush()
}
export {
BAR_CELL, CHART_H, TrendChart, assertColor, bars, kit, makeSpies, propsOf, req, scrollEvent, scrollTo, viewport,
}
export type { LayoutEl }