From b7af498427307407b84b3b8b46fc13345075d99f Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 1 Sep 2026 00:30:58 +0800 Subject: [PATCH] perf: stabilize renders (transcript row reuse, tree Sets, shared + options, batched drags) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SideChat transcript: every 2s poll rebuilt ALL row objects, so every row (markdown parse + DOMPurify included) re-rendered even when the log had not changed. transcriptRows now takes the previous poll's rows and re-adopts the position-aligned rows whose content is identical (reuseRows) — only the changed tail rebuilds. The view feeds the memoized array back through a ref; unit tests pin the identity contract (unchanged rows keep object identity, changed rows and everything past the change rebuild, tool-result landings never re-adopt). FileTree: expanded/revealed membership was an includes() scan per rendered row (O(rows × expanded) per render on deep trees); one Set per render keeps it O(1) per row. Shell: buildNewTabOptions was inlined in the two Workbench JSX sites — two fresh arrays per render (drag state, resizes) re-rendered every LeafView's + affordance. One shared memo now. DiffPane resize / TerminalView ResizeObserver: pointer and resize streams fired a setState / glyph-measuring fit() per event; both ride per-frame batching now (frame-batcher in the core, a local rAF gate in the lazy terminal chunk). SessionLens sizes each row's content with a Blob (a UTF-8 encode of the whole edit) — that moved from per-row-per-render into one memoized pass per changed fold. --- src/client/FileTree.tsx | 18 +++++--- src/client/SideChatView.tsx | 13 +++++- src/client/Sidebar.tsx | 18 +++++++- src/client/TerminalView.tsx | 23 +++++++--- src/client/changes/DiffPane.tsx | 9 +++- src/client/changes/SessionLens.tsx | 18 +++++++- src/client/sidechat-transcript.ts | 56 ++++++++++++++++++++++- tests/sidechat-transcript.spec.ts | 72 ++++++++++++++++++++++++++++++ 8 files changed, 207 insertions(+), 20 deletions(-) diff --git a/src/client/FileTree.tsx b/src/client/FileTree.tsx index 942a06f..684ceb4 100644 --- a/src/client/FileTree.tsx +++ b/src/client/FileTree.tsx @@ -20,7 +20,7 @@ * (VSCode semantics — a drop on a file row targets its parent directory), * and `busy` gates new drags while one upload is in flight. */ -import { useCallback, useEffect, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import { @@ -441,6 +441,12 @@ export function FileTree(props: { const root = cwd + // Membership is tested per rendered row (deep trees run this thousands of + // times per render): includes() made every row O(expanded), the whole tree + // O(rows × expanded). One Set per render keeps it O(1) per row. + const expandedSet = useMemo(() => new Set(expanded), [expanded]) + const revealedSet = useMemo(() => new Set(revealed), [revealed]) + const renderLevel = (dir: string, depth: number): ReactNode => { const level = data[dir] if (level === undefined) { @@ -466,7 +472,7 @@ export function FileTree(props: { const entries = level.entries ?? [] return entries.map(entry => { if (entry.isDir) { - const isOpen = expanded.includes(entry.path) + const isOpen = expandedSet.has(entry.path) return (
{ onToggle(entry.path) }} onKeyDown={(event) => { @@ -507,9 +513,9 @@ export function FileTree(props: { className={clsx( css.explorerRow, entry.hidden && css.explorerHidden, entry.broken && css.explorerBroken, dropTarget === parentOf(entry.path) && css.explorerRowDropTarget, - revealed.includes(entry.path) && css.explorerRowRevealed, + revealedSet.has(entry.path) && css.explorerRowRevealed, )} - data-dsh-revealed={revealed.includes(entry.path) ? 'true' : undefined} + data-dsh-revealed={revealedSet.has(entry.path) ? 'true' : undefined} style={{ paddingLeft: depth * 22 + 6 }} title={entry.broken ? `${entry.path} — ${t('brokenSymlink')}` : entry.path} onClick={() => { onOpenFile(entry.path) }} diff --git a/src/client/SideChatView.tsx b/src/client/SideChatView.tsx index e409c1a..f6ed825 100644 --- a/src/client/SideChatView.tsx +++ b/src/client/SideChatView.tsx @@ -383,6 +383,8 @@ export function SideChatView(props: { const [menuOpen, setMenuOpen] = useState(false) const cacheRef = useRef({ entries: [] }) + // The previous poll's rows (see the mapping's reuse pass below). + const prevRowsRef = useRef([]) const controllerRef = useRef(null) const scrollRef = useRef(null) const composerRef = useRef(null) @@ -480,6 +482,7 @@ export function SideChatView(props: { // the composer — it owns the first message of a fresh thread. useEffect(() => { cacheRef.current = { entries: [] } + prevRowsRef.current = [] controllerRef.current?.abort() setError(null) setSaved(false) @@ -517,8 +520,14 @@ export function SideChatView(props: { } }, [connectionState, threadId, fetchThread, fetchInfo]) - const rows = useMemo( - () => (threadId === undefined ? [] : transcriptRows(cacheRef.current.entries)), + // The previous poll's rows ride into the mapping so unchanged rows keep + // their object identity (see reuseRows): the 2s poll re-renders only the + // changed tail instead of re-parsing markdown for the whole transcript. + const rows = useMemo(() => { + const next = threadId === undefined ? [] : transcriptRows(cacheRef.current.entries, prevRowsRef.current) + prevRowsRef.current = next + return next + }, // The cache is a ref; revision bumps on every successful pull. // eslint-disable-next-line react-hooks/exhaustive-deps [threadId, revision], diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 891fa32..4f71da1 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -407,6 +407,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { }, [sessionId, summaryCwd]) const cwd = summaryCwd ?? fetchedCwd + // The + menu options ride a memo so the two Workbenches share ONE array + // identity across renders that did not change the store (drag state, + // viewport resize): fresh arrays per render re-rendered every LeafView's + // + affordance whether or not anything tab-related moved. + const newTabOptions = useMemo( + () => (state === undefined || sessionId === undefined ? [] : buildNewTabOptions(state, ctx, { sessionId, cwd })), + // state is the whole session state — every field it wraps is fair game + // for the descriptors' available() callbacks. (The render's own guard + // sits below every hook; this memo must handle the no-session case + // itself.) + // eslint-disable-next-line react-hooks/exhaustive-deps + [state, ctx, sessionId, cwd], + ) + /** * Agent terminals push: subscribe to the host's live list of agent-owned * terminals for this session (created by the model through the @@ -1581,7 +1595,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { renderTab(tab, active, paneId, 'bottom')} diff --git a/src/client/TerminalView.tsx b/src/client/TerminalView.tsx index eba3d93..ffaee8a 100644 --- a/src/client/TerminalView.tsx +++ b/src/client/TerminalView.tsx @@ -268,13 +268,23 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store: const inputSub = term.onData((data) => { if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(data) }) + // Resize streams fire per layout frame during panel open/close + // animations; fit() measures glyphs, so coalesce to one fit per + // animation frame (same pattern as the core's frame-batcher, kept + // local — this view lives in the lazy terminal chunk and does not + // import core-bundle modules). + let resizeFrame: number | null = null const observer = new ResizeObserver(() => { - try { - fit.fit() - sendResize() - } catch { - // The terminal may be mid-dispose; ignore. - } + if (resizeFrame !== null) return + resizeFrame = requestAnimationFrame(() => { + resizeFrame = null + try { + fit.fit() + sendResize() + } catch { + // The terminal may be mid-dispose; ignore. + } + }) }) observer.observe(host) @@ -323,6 +333,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store: cancelOpen() window.clearTimeout(retry) observer.disconnect() + if (resizeFrame !== null) cancelAnimationFrame(resizeFrame) fontSub() schemeSub() inputSub.dispose() diff --git a/src/client/changes/DiffPane.tsx b/src/client/changes/DiffPane.tsx index c8ff841..436cb53 100644 --- a/src/client/changes/DiffPane.tsx +++ b/src/client/changes/DiffPane.tsx @@ -21,6 +21,7 @@ import { DiffFiles } from '../diff/DiffFiles.tsx' import { langOfPath } from '../diff/highlight.ts' import { buildDiffSegments, diffLines, diffStats, parseUnifiedDiff, unifiedSegments, type DiffRow } from '../diff/rows.ts' import { parseReadLines, type FileOp } from './ops.ts' +import { createFrameBatcher } from '../frame-batcher.ts' import css from './changes.module.css' import diffCss from '../diff/diff.module.css' @@ -176,17 +177,23 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp const paneHeight = dragHeight ?? height const clamp = (value: number): number => Math.min(Math.max(value, HEIGHT_MIN), Math.round(window.innerHeight * 0.7)) const dragOrigin = useRef<{ y: number; h: number } | null>(null) + // Pointer streams fire several times per frame; one setState per event + // re-rendered the whole pane at event cadence (see frame-batcher). + const dragBatcher = useRef(createFrameBatcher()).current + useEffect(() => () => dragBatcher.dispose(), [dragBatcher]) const onHandleDown = (event: ReactPointerEvent): void => { event.preventDefault() dragOrigin.current = { y: event.clientY, h: paneHeight } const onMove = (ev: PointerEvent): void => { if (dragOrigin.current === null) return - setDragHeight(clamp(dragOrigin.current.h + (dragOrigin.current.y - ev.clientY))) + const next = clamp(dragOrigin.current.h + (dragOrigin.current.y - ev.clientY)) + dragBatcher.schedule(() => { setDragHeight(next) }) } const onUp = (): void => { window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onUp) dragOrigin.current = null + dragBatcher.flushNow() setDragHeight(current => { if (current !== null) onHeightCommit(current) return null diff --git a/src/client/changes/SessionLens.tsx b/src/client/changes/SessionLens.tsx index 8b8e6a1..8e33319 100644 --- a/src/client/changes/SessionLens.tsx +++ b/src/client/changes/SessionLens.tsx @@ -54,6 +54,20 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi ) + // Sizing a row's content constructs a Blob (a UTF-8 encode of the whole + // edit) — that was per row PER RENDER, re-encoding every field on every + // poll tick. ops keeps its identity between unchanged polls (see the + // tab's opsRef), so one pass per changed fold covers every render. + const opSizes = useMemo(() => { + const sizes = new Map() + for (const op of ops) { + if (op.kind !== 'read' && !op.isError) { + sizes.set(op.callId, formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size)) + } + } + return sizes + }, [ops]) + return (
@@ -85,9 +99,9 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi {op.running && {t('changesRunning')}} {op.isError && {t('changesError')}} - {op.kind !== 'read' && !op.isError && ( + {opSizes.get(op.callId) !== undefined && ( - {formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size)} + {opSizes.get(op.callId)} )} {relativeTime(new Date(op.time).toISOString())} diff --git a/src/client/sidechat-transcript.ts b/src/client/sidechat-transcript.ts index 61dde7a..0c9e400 100644 --- a/src/client/sidechat-transcript.ts +++ b/src/client/sidechat-transcript.ts @@ -307,7 +307,7 @@ function resultCard( * @param entries - history rows (event + host-computed view) in seq order. * @returns display rows in log order. */ -export function transcriptRows(entries: readonly SidebarHistoryEntry[]): SidechatTranscriptRow[] { +export function transcriptRows(entries: readonly SidebarHistoryEntry[], prev?: readonly SidechatTranscriptRow[]): SidechatTranscriptRow[] { const events = entries.map(entry => entry.event) const seedEnd = lastSeedEnd(events) const rows: SidechatTranscriptRow[] = [] @@ -484,5 +484,59 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[]): Sidecha } } } + return reuseRows(rows, prev) +} + +/** Whether two rows carry identical display content (identity fields plus + * every rendered field of their kind). */ +function rowsEqual(a: SidechatTranscriptRow, b: SidechatTranscriptRow): boolean { + if (a.kind !== b.kind || a.seq !== b.seq) return false + switch (a.kind) { + case 'user': + case 'injection': { + const other = b as typeof a + return a.text === other.text + } + case 'assistant': + case 'reasoning': { + const other = b as typeof a + return a.text === other.text && a.settled === other.settled + } + case 'tool': { + // The card is NOT compared by value: its content is a pure function of + // the call-time args and the tool/result landing, and every transition + // (output/exit arriving) flips `executing`/`failed`/`resultText` — + // compared here — so a card whose content changed never slips through + // an equal compare, and an equal compare re-adopting the OLD card + // object (identical content, stale reference) only helps React skip. + const other = b as typeof a + return a.name === other.name && a.failed === other.failed && a.args === other.args + && a.resultText === other.resultText && a.executing === other.executing + } + case 'turnSummary': { + const other = b as typeof a + return a.inputTokens === other.inputTokens && a.outputTokens === other.outputTokens + && a.durationMs === other.durationMs + } + } +} + +/** Re-adopt the PREVIOUS poll's row objects wherever the content is + * unchanged (position-aligned, append-mostly): settled rows keep their + * object identity across the 2s polls, so React's reconciler and the + * markdown/DOMPurify caches downstream skip them instead of re-rendering + * the whole transcript every poll. Comparing the strings is far cheaper + * than what a fresh reference costs the row below. Rows past the first + * mismatch (an inserted/superseded streaming row) rebuild as usual — that + * is exactly the changed tail. */ +function reuseRows(rows: SidechatTranscriptRow[], prev: readonly SidechatTranscriptRow[] | undefined): SidechatTranscriptRow[] { + if (prev === undefined) return rows + const shared = Math.min(rows.length, prev.length) + for (let index = 0; index < shared; index++) { + const before = prev[index] + const after = rows[index] + if (before === undefined || after === undefined) continue + if (rowsEqual(before, after)) rows[index] = before + } return rows } diff --git a/tests/sidechat-transcript.spec.ts b/tests/sidechat-transcript.spec.ts index fbe5a6a..6c8e532 100644 --- a/tests/sidechat-transcript.spec.ts +++ b/tests/sidechat-transcript.spec.ts @@ -371,3 +371,75 @@ describe('toolArgsSummary', () => { expect(toolArgsSummary('{"command":" "}')).toBe('{"command":" "}') }) }) + +describe('transcriptRows row reuse (the poll-to-poll identity pass)', () => { + it('re-adopts the previous poll\'s row objects wherever content is unchanged', () => { + const entries = [ + entry(ev('session/end-seed', 0)), + entry(ev('user/message', 1, { content: textBlocks('the side question'), source: { kind: 'user' } })), + entry(ev('assistant/message', 2, { message: { content: textBlocks('the answer') } })), + ] + const first = transcriptRows(entries) + const second = transcriptRows(entries, first) + expect(second).toEqual(first) + // Settled rows keep their object identity (fresh references defeat the + // downstream memo and re-render the whole transcript every 2s poll). + expect(second[1]).toBe(first[1]) + expect(second[2]).toBe(first[2]) + }) + + it('rebuilds changed rows and everything past the change', () => { + const before = transcriptRows([ + entry(ev('session/end-seed', 0)), + entry(ev('user/message', 1, { content: textBlocks('q'), source: { kind: 'user' } })), + ]) + const after = transcriptRows([ + entry(ev('session/end-seed', 0)), + entry(ev('user/message', 1, { content: textBlocks('q'), source: { kind: 'user' } })), + entry(ev('user/message', 2, { content: textBlocks('q2'), source: { kind: 'user' } })), + ], before) + expect(after).toHaveLength(2) + expect(after[0]).toBe(before[0]) + expect(after[1]).not.toBe(before[1]) + expect(after[1]).toMatchObject({ kind: 'user', seq: 2, text: 'q2' }) + }) + + it('never re-adopts a row whose rendered fields changed (tool result lands)', () => { + const call = entry(ev('tool/call', 1, { callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' })) + const before = transcriptRows([entry(ev('session/end-seed', 0)), call]) + expect(before[0]).toMatchObject({ kind: 'tool', executing: true }) + const result = entry(ev('tool/result', 2, { + message: { source: { callId: 'c1' }, content: [{ type: 'tool-result', content: [{ type: 'text', text: 'out' }] }] }, + })) + const after = transcriptRows([entry(ev('session/end-seed', 0)), call, result], before) + expect(after[0]).not.toBe(before[0]) + expect(after[0]).toMatchObject({ kind: 'tool', executing: false, resultText: 'out' }) + }) +}) + +describe('transcriptRows row reuse (newer row kinds)', () => { + it('re-adopts settled turnSummary rows (token usage tail)', () => { + const entries = [ + entry(ev('session/end-seed', 0)), + entry(ev('turn/start', 1, { turn: 1 })), + entry(ev('turn/end', 2, { turn: 1, reason: 'completed' })), + ] + const first = transcriptRows(entries) + const summary = first.find(row => row.kind === 'turnSummary') + expect(summary).toBeDefined() + const second = transcriptRows(entries, first) + expect(second[second.length - 1]).toBe(summary) + }) + + it('re-adopts a tool row whose card landed (executing flip forces the rebuild)', () => { + const call = entry(ev('tool/call', 1, { callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' })) + const before = transcriptRows([entry(ev('session/end-seed', 0)), call]) + expect(before[0]).toMatchObject({ kind: 'tool', executing: true }) + const result = entry(ev('tool/result', 2, { + message: { source: { callId: 'c1' }, content: [{ type: 'tool-result', content: [{ type: 'text', text: 'out\n[exit 0]' }] }] }, + })) + const after = transcriptRows([entry(ev('session/end-seed', 0)), call, result], before) + expect(after[0]).not.toBe(before[0]) + expect(after[0]).toMatchObject({ kind: 'tool', executing: false }) + }) +})