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:
Menghuan1918
2026-09-01 16:01:34 +08:00
parent 427552e4f9
commit b7af498427
8 changed files with 207 additions and 20 deletions
+55 -1
View File
@@ -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
}