perf: stabilize renders (transcript row reuse, tree Sets, shared + options, batched drags)
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.
This commit is contained in:
+12
-6
@@ -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 (
|
||||
<div key={entry.path}>
|
||||
<div
|
||||
@@ -475,9 +481,9 @@ export function FileTree(props: {
|
||||
className={clsx(
|
||||
css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden,
|
||||
dropTarget === 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 }}
|
||||
onClick={() => { 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) }}
|
||||
|
||||
@@ -383,6 +383,8 @@ export function SideChatView(props: {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const cacheRef = useRef<ThreadCache>({ entries: [] })
|
||||
// The previous poll's rows (see the mapping's reuse pass below).
|
||||
const prevRowsRef = useRef<SidechatTranscriptRow[]>([])
|
||||
const controllerRef = useRef<AbortController | null>(null)
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(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],
|
||||
|
||||
+16
-2
@@ -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 }) {
|
||||
<Workbench
|
||||
state={state}
|
||||
tree={augmentedTree}
|
||||
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
|
||||
newTabOptions={newTabOptions}
|
||||
actions={wrappedActions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
@@ -1736,7 +1750,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
<Workbench
|
||||
state={state}
|
||||
tree={state.bottomSplits}
|
||||
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
|
||||
newTabOptions={newTabOptions}
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'bottom')}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<HTMLDivElement>): 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
|
||||
|
||||
@@ -54,6 +54,20 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi
|
||||
</button>
|
||||
)
|
||||
|
||||
// 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<string, string>()
|
||||
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 (
|
||||
<div className={css.session}>
|
||||
<div className={css.filterRow} role="group" aria-label={t('changesSessionLens')}>
|
||||
@@ -85,9 +99,9 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi
|
||||
{op.running && <span className={css.opFlag}>{t('changesRunning')}</span>}
|
||||
{op.isError && <span className={css.opFlagError}>{t('changesError')}</span>}
|
||||
<span className={css.opMeta}>
|
||||
{op.kind !== 'read' && !op.isError && (
|
||||
{opSizes.get(op.callId) !== undefined && (
|
||||
<span className={css.opSize}>
|
||||
{formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size)}
|
||||
{opSizes.get(op.callId)}
|
||||
</span>
|
||||
)}
|
||||
<span className={css.opTime}>{relativeTime(new Date(op.time).toISOString())}</span>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user