[eric] canvas: seven first-frame costs off the gesture path (transcript measure, batched flush, pan hold, chat memo, pan cursor, pill-shot and suspend encodes)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012G8kyALnPjsA7aJFmMBq3R
This commit is contained in:
ciregenz
2026-09-02 19:01:21 -07:00
co-authored by Claude Fable 5.1
parent 461364789a
commit adbccd7e04
12 changed files with 286 additions and 29 deletions
+25 -2
View File
@@ -1,4 +1,6 @@
import React, { useEffect, useRef, useMemo, useState, useCallback } from 'react';
import { interactionActive } from '@/shared/interactionPriority';
import { perfBaseline } from '@/shared/perfBaseline';
import { useParams } from 'react-router-dom';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
@@ -1479,7 +1481,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
viewportWidthRef.current = viewportWidth;
// Measure mounted item heights so the spacers that stand in for unmounted items keep the scrollbar geometry stable (no jump when unloading above).
React.useLayoutEffect(() => {
const measureWindowItems = useCallback(() => {
const el = scrollContainerRef.current;
if (!el) return;
let changed = false;
@@ -1496,7 +1498,27 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
});
// Guarded so this converges: once heights stop moving, no more version bumps.
if (changed) setHeightVersion((v) => v + 1);
}, []);
// offsetHeight forces a synchronous style-and-layout pass over the whole transcript, and with no dependency list this ran on EVERY commit of every open chat: under eight agents it was the single worst function on the main thread (535 ms inside one 5.6 s pan, 2026-09-02). Mounted heights only move when the mounted set changes or something inside an item resizes, so measure on those two signals and never while a gesture is live; the ResizeObserver below reports after layout, so it forces nothing.
const mountedIdsKey = renderedVisibleItems.map((item) => item.id).join('\n');
const measuredIdsRef = useRef('');
React.useLayoutEffect(() => {
if (perfBaseline()) { measureWindowItems(); return; }
if (mountedIdsKey === measuredIdsRef.current || interactionActive()) return;
measuredIdsRef.current = mountedIdsKey;
measureWindowItems();
});
useEffect(() => {
const el = scrollContainerRef.current;
if (!el || typeof ResizeObserver === 'undefined' || perfBaseline()) return;
let raf: number | null = null;
const observer = new ResizeObserver(() => {
if (interactionActive() || raf !== null) return;
raf = requestAnimationFrame(() => { raf = null; measureWindowItems(); });
});
el.querySelectorAll<HTMLElement>('[data-window-item-id]').forEach((node) => observer.observe(node));
return () => { observer.disconnect(); if (raf !== null) cancelAnimationFrame(raf); };
}, [mountedIdsKey, measureWindowItems]);
// Spacers reserve the cumulative height of the unmounted items above/below the window. heightVersion gates recompute off the ref-held measurements; we index the render-scope renderItems directly so id->height stays correct on the frame the transcript changes.
const topSpacerHeight = useMemo(() => {
@@ -2679,4 +2701,5 @@ function FreeTrialModelNotice({ c, notice }: { c: ReturnType<typeof useClaudeTok
);
}
export default AgentChat;
// Memoized so the card around it can change chrome (drag, glow, selection) without re-rendering the transcript; the seam keeps the old behaviour for A/B runs.
export default perfBaseline() ? AgentChat : React.memo(AgentChat);
@@ -458,13 +458,11 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
backgroundSize: washLayers.size,
backgroundRepeat: washLayers.repeat,
} : {}),
cursor: canvas.isPanning
? 'grabbing'
: (canvas.spaceHeld || canvas.cmdHeld)
? 'grab'
: selection.marquee
? 'crosshair'
: 'default',
cursor: (canvas.spaceHeld || canvas.cmdHeld)
? 'grab'
: selection.marquee
? 'crosshair'
: 'default',
}}
>
@@ -437,6 +437,9 @@ const AgentCard: React.FC<Props> = ({
const DRAG_THRESHOLD = 3;
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
// Stable callbacks so the memoized chat never re-renders for the card's own chrome (a drag, a glow): under load one such render cost 1.6 s.
const closeChat = useCallback(() => { dispatch(collapseSession(session.id)); }, [dispatch, session.id]);
const branchChat = useMemo(() => (onBranch ? (newId: string) => onBranch(session.id, newId) : undefined), [onBranch, session.id]);
const [isDragging, setIsDragging] = useState(false);
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
const didDrag = useRef(false);
@@ -1252,13 +1255,13 @@ const AgentCard: React.FC<Props> = ({
<AgentChat
key={session.id}
sessionId={session.id}
onClose={() => dispatch(collapseSession(session.id))}
onClose={closeChat}
embedded
fullscreenChat={isFullscreen}
autoFocus={autoFocusInput}
isGlowing={isGlowingRedux && !glowFading}
onDismissGlow={dismissGlow}
onBranch={onBranch ? (newId: string) => onBranch(session.id, newId) : undefined}
onBranch={branchChat}
/>
) : (
<Box sx={{ flex: 1 }} />
@@ -65,7 +65,11 @@ import {
wakePendingLoad,
hasDomReady,
type BrowserWebview,
type ElectronNativeImage,
} from '@/shared/browserRegistry';
import { interactionActive } from '@/shared/interactionPriority';
import { perfBaseline } from '@/shared/perfBaseline';
import { encodeShotWhenIdle } from '@/shared/encodeShotWhenIdle';
import { captureBrowserShot } from '@/shared/captureBrowserShot';
import { setLastInteractedBrowser } from '@/shared/browserFocus';
import { isAgentDrivenBrowser } from '@/shared/isAgentDrivenBrowser';
@@ -96,6 +100,8 @@ import { useCanvasWindowResize } from './useCanvasWindowResize';
const PILL_SHOT_WARMUP_MS = 800;
const PILL_SHOT_REFRESH_MS = 5000;
const PILL_SHOT_WARMUP_MAX_MS = 8000;
// The pill miniature is 320px wide, so a retina-2x shot is plenty and a quarter of a full-page encode.
const PILL_SHOT_MAX_W = 640;
const MIN_W = 400;
const MIN_H = 300;
@@ -1149,19 +1155,23 @@ const BrowserCard: React.FC<Props> = ({
let inFlight = false;
const freeze = (): void => {
// Capturing a webview an agent is mid-command on is the SharedImage-mailbox renderer crash.
if (inFlight || isAnyBrowserBusy()) return;
// Mid-gesture the capture's PNG encode (160-200 ms) landed on the drag's own frames; the next 5 s tick catches up.
if (inFlight || isAnyBrowserBusy() || (!perfBaseline() && interactionActive())) return;
const wv = webviewMap.current.get(activeTabId);
// capturePage THROWS on a guest that hasn't reached dom-ready yet, and an uncaught one here kills the whole card tree.
if (!wv || !hasDomReady(wv)) return;
let shot: Promise<{ isEmpty: () => boolean; toDataURL: () => string }> | undefined;
let shot: Promise<ElectronNativeImage> | undefined;
try { shot = wv.capturePage(); } catch { return; }
if (!shot) return;
inFlight = true;
shot.then((img) => {
inFlight = false;
if (cancelled || img.isEmpty()) return;
saveMinimizedShot(browserId, img.toDataURL());
setPillShotSettled(true);
encodeShotWhenIdle(img, PILL_SHOT_MAX_W, (dataUrl) => {
if (cancelled || !dataUrl) return;
saveMinimizedShot(browserId, dataUrl);
setPillShotSettled(true);
});
}, () => { inFlight = false; });
};
freeze();
@@ -0,0 +1,37 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
// Three first-frame costs measured on a loaded board (2026-09-02): a React state flip for the pan cursor
// (77-100 ms sync render), a full-page PNG encode per pill-tucked browser every 5 s (160-200 ms each,
// mid-gesture included), and the suspend pass encoding every visible card right after a pan committed.
// Each is pinned in the source, so a later edit cannot quietly put one back.
// Tests run bundled out of .test-build, so sources resolve from the frontend root, not from this file.
const here = path.join(process.cwd(), 'src/app/pages/Dashboard/hooks/interaction');
const read = (rel: string) => readFileSync(path.resolve(here, rel), 'utf8');
test('the pan cursor is a style write on the viewport, not React state', () => {
const src = read('useCanvasControls.ts');
assert.ok(!/setIsPanning|\[isPanning/.test(src), 'isPanning state is back');
assert.match(src, /vp\.style\.cursor = panning \? 'grabbing' : ''/);
const canvas = read('../../canvas/DashboardCanvas.tsx');
assert.ok(!canvas.includes('canvas.isPanning'), 'DashboardCanvas reads a panning state again');
});
test('the pill shot skips a live gesture and encodes shrunk, in an idle slot', () => {
const src = read('../../cards/BrowserCard.tsx');
const freeze = src.slice(src.indexOf('const freeze = (): void => {'), src.indexOf('freeze();'));
assert.match(freeze, /isAnyBrowserBusy\(\) \|\| \(!perfBaseline\(\) && interactionActive\(\)\)/);
assert.match(freeze, /encodeShotWhenIdle\(img, PILL_SHOT_MAX_W/);
assert.ok(!freeze.includes('img.toDataURL()'), 'the pill shot encodes on the capture callback again');
});
test('the suspend pass waits for the gesture and its captures encode idle', () => {
const src = read('useWebviewSuspend.ts');
const refresh = src.slice(src.indexOf('async function refreshVisibleFrames'), src.indexOf('async function captureForSuspend'));
assert.match(refresh, /isAnyBrowserBusy\(\) \|\| \(!perfBaseline\(\) && interactionActive\(\)\)/);
const capture = src.slice(src.indexOf('async function captureCard'));
assert.match(capture, /encodeShotWhenIdle\(image, SNAPSHOT_MAX_W/);
assert.ok(!capture.includes('.toDataURL()'), 'captureCard encodes synchronously again');
});
@@ -89,7 +89,11 @@ export function useCanvasControls(
const gridRef = useRef<HTMLDivElement>(null);
const [state, setState] = useState<CanvasState>({ panX: 0, panY: 0, zoom: 1 });
const [isPanning, setIsPanning] = useState(false);
// The grab cursor is a style write, not state: a state flip on mousedown rendered the whole board synchronously (77-100 ms under load) before the first pan frame.
const setPanCursor = useCallback((panning: boolean) => {
const vp = viewportRef.current;
if (vp) vp.style.cursor = panning ? 'grabbing' : '';
}, []);
const [spaceHeld, setSpaceHeld] = useState(false);
const [cmdHeld, setCmdHeld] = useState(false);
@@ -535,7 +539,7 @@ export function useCanvasControls(
e.preventDefault();
cancelAnimation();
cancelInertia();
setIsPanning(true);
setPanCursor(true);
setCanvasInteractionActive(true);
velocityHistoryRef.current = [{ x: e.clientX, y: e.clientY, t: performance.now() }];
panStartRef.current = {
@@ -544,7 +548,7 @@ export function useCanvasControls(
panX: stateRef.current.panX,
panY: stateRef.current.panY,
};
}, [cancelAnimation, cancelInertia]);
}, [cancelAnimation, cancelInertia, setPanCursor]);
// RAF-coalesce drag pan; setState per event caused "hop hop hop" feel. Velocity history still captures per-event for inertia accuracy.
const dragRafRef = useRef<number | null>(null);
@@ -608,7 +612,7 @@ export function useCanvasControls(
velocityHistoryRef.current = [];
}
panStartRef.current = null;
setIsPanning(false);
setPanCursor(false);
setCanvasInteractionActive(false);
// Inertia keeps writing live and commits when it settles; otherwise this gesture ends here.
if (!didInertia) commitLive();
@@ -616,7 +620,7 @@ export function useCanvasControls(
if (wasPanning && !didInertia) {
springBackIfNeeded();
}
}, [startInertia, springBackIfNeeded, commitLive]);
}, [startInertia, springBackIfNeeded, commitLive, setPanCursor]);
// Releasing the button OUTSIDE the window means our window never sees the mouseup, so the pan latch
// stayed armed and the canvas followed the cursor forever, with no way to escape the app (ENG-257).
@@ -626,7 +630,7 @@ export function useCanvasControls(
const release = () => {
if (panStartRef.current) {
panStartRef.current = null;
setIsPanning(false);
setPanCursor(false);
setCanvasInteractionActive(false);
commitLive();
}
@@ -642,7 +646,7 @@ export function useCanvasControls(
window.removeEventListener('blur', release);
window.removeEventListener('mousemove', onStrayMove, true);
};
}, [commitLive]);
}, [commitLive, setPanCursor]);
useEffect(() => {
return () => { cancelAnimation(); cancelInertia(); };
@@ -953,7 +957,6 @@ export function useCanvasControls(
return {
...state,
isPanning,
spaceHeld,
cmdHeld,
viewportRef,
@@ -8,6 +8,9 @@ import {
type BrowserCardPosition,
} from '@/shared/state/dashboardLayoutSlice';
import { getWebview } from '@/shared/browserRegistry';
import { interactionActive } from '@/shared/interactionPriority';
import { perfBaseline } from '@/shared/perfBaseline';
import { encodeShotWhenIdle } from '@/shared/encodeShotWhenIdle';
import { getActivity, isAnyBrowserBusy } from '@/shared/browserCommandHandler';
import { isKeepAliveBrowser } from '@/shared/browserFocus';
import { captureTabCapsule } from '@/shared/browserStateCapsule';
@@ -275,8 +278,8 @@ async function refreshVisibleFrames(
isSuspended: (id: string) => boolean,
vp: Viewport,
): Promise<void> {
// Capturing while an agent drives a webview is the SharedImage-mailbox crash class; skip the whole pass.
if (isAnyBrowserBusy()) return;
// Capturing while an agent drives a webview is the SharedImage-mailbox crash class; skip the whole pass. Same mid-gesture: this ran 800 ms after a pan committed, i.e. on the next pan's first frames.
if (isAnyBrowserBusy() || (!perfBaseline() && interactionActive())) return;
for (const [id, card] of Object.entries(cards)) {
if (isSuspended(id)) continue;
if (isMinimized(id)) continue;
@@ -315,9 +318,7 @@ async function captureCard(id: string, card: BrowserCardPosition): Promise<strin
new Promise<null>((resolve) => setTimeout(() => resolve(null), CAPTURE_TIMEOUT_MS)),
]);
if (!image || image.isEmpty()) return '';
return image.getSize().width > SNAPSHOT_MAX_W
? image.resize({ width: SNAPSHOT_MAX_W, quality: 'good' }).toDataURL()
: image.toDataURL();
return await new Promise<string>((resolve) => encodeShotWhenIdle(image, SNAPSHOT_MAX_W, resolve));
} catch {
return '';
}
@@ -0,0 +1,59 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { encodeShotWhenIdle } from './encodeShotWhenIdle';
import { markInteraction } from './interactionPriority';
import type { ElectronNativeImage } from './browserRegistry';
function fakeImage(width: number, log: string[], encoder?: () => string): ElectronNativeImage {
return {
isEmpty: () => false,
getSize: () => ({ width, height: Math.round(width * 0.6) }),
resize: (o) => { log.push(`resize:${o.width}`); return fakeImage(o.width ?? width, log, encoder); },
toDataURL: () => { log.push(`encode:${width}`); return encoder ? encoder() : `data:image/png;base64,${width}`; },
toPNG: () => Buffer.alloc(0),
toJPEG: () => Buffer.alloc(0),
};
}
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
const DECAY_AND_ONE_WAIT_MS = 350 + 400 + 150;
test('a shot wider than the cap is shrunk before the encode, and never encoded on the caller\'s own frame', async () => {
const log: string[] = [];
let out: string | null = null;
encodeShotWhenIdle(fakeImage(2400, log), 640, (u) => { out = u; });
assert.equal(out, null);
assert.deepEqual(log, []);
await wait(40);
assert.deepEqual(log, ['resize:640', 'encode:640']);
assert.equal(out, 'data:image/png;base64,640');
});
test('a shot within the cap encodes as-is', async () => {
const log: string[] = [];
let out: string | null = null;
encodeShotWhenIdle(fakeImage(500, log), 640, (u) => { out = u; });
await wait(40);
assert.deepEqual(log, ['encode:500']);
assert.equal(out, 'data:image/png;base64,500');
});
test('mid-gesture the encode waits for the gesture to end instead of landing on its frames', async () => {
const log: string[] = [];
let out: string | null = null;
markInteraction();
encodeShotWhenIdle(fakeImage(800, log), 640, (u) => { out = u; });
await wait(150);
assert.equal(out, null, 'encoded while the gesture was live');
assert.deepEqual(log, []);
await wait(DECAY_AND_ONE_WAIT_MS);
assert.deepEqual(log, ['resize:640', 'encode:640']);
assert.equal(out, 'data:image/png;base64,640');
});
test('an encoder that throws reports an empty string rather than killing the caller', async () => {
const log: string[] = [];
let out: string | null = null;
encodeShotWhenIdle(fakeImage(300, log, () => { throw new Error('codec'); }), 640, (u) => { out = u; });
await wait(40);
assert.equal(out, '');
});
+36
View File
@@ -0,0 +1,36 @@
import type { ElectronNativeImage } from '@/shared/browserRegistry';
import { interactionActive } from '@/shared/interactionPriority';
import { perfBaseline } from '@/shared/perfBaseline';
// A gesture that outlives this many waits gets its encode anyway, so a long pan cannot starve a shot forever.
const MAX_GESTURE_WAITS = 6;
const GESTURE_WAIT_MS = 400;
const IDLE_TIMEOUT_MS = 1500;
// PNG-encoding a full-page capture blocks the main thread for ~180 ms; shrinking first and encoding in an idle slot keeps it off every gesture frame.
export function encodeShotWhenIdle(
image: ElectronNativeImage,
maxWidth: number,
done: (dataUrl: string) => void,
): void {
let waits = 0;
const run = (): void => {
if (!perfBaseline() && interactionActive() && waits < MAX_GESTURE_WAITS) {
waits += 1;
window.setTimeout(run, GESTURE_WAIT_MS);
return;
}
let dataUrl = '';
try {
const sized = image.getSize().width > maxWidth ? image.resize({ width: maxWidth, quality: 'good' }) : image;
dataUrl = sized.toDataURL();
} catch {
dataUrl = '';
}
done(dataUrl);
};
// The A/B seam keeps the old shape: encode right here, on whatever frame the capture landed in.
if (perfBaseline()) run();
else if (typeof requestIdleCallback === 'function') requestIdleCallback(() => run(), { timeout: IDLE_TIMEOUT_MS });
else window.setTimeout(run, 0);
}
+15
View File
@@ -0,0 +1,15 @@
// Drill seam for interleaved A/B runs on one live board: `localStorage.setItem('osw.perf.baseline', '1')`
// restores the pre-2026-09-02 gesture behaviour (measure transcript heights on every commit, flush every
// held stream in the same tick). Read once per page load, so flipping an arm is a reload, never a rebuild.
let p_cached: boolean | null = null;
export function perfBaseline(): boolean {
if (p_cached === null) {
try {
p_cached = localStorage.getItem('osw.perf.baseline') === '1';
} catch {
p_cached = false;
}
}
return p_cached;
}
+38 -1
View File
@@ -36,6 +36,7 @@ import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '.
import { remountAppPreview } from '../state/outputsSlice';
import { BackgroundDeltaBuffer } from './BackgroundDeltaBuffer';
import { interactionActive, installInteractionListeners } from '../interactionPriority';
import { perfBaseline } from '@/shared/perfBaseline';
import { addBrowserCardFromBackend, setBrowserDocked, markBrowserCardEnding, keepBrowserCardOpen, placeBesideCard, placeBelowCard, placeBrowserBesideChat, setBrowserCardPosition, setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, removeBrowserCard, GRID_GAP, WORKFLOW_CARD_GAP, openWorkflowsApp } from '../state/dashboardLayoutSlice';
import { upsertOutput } from '../state/outputsSlice';
import { setCardPosition } from '../state/dashboardLayoutSlice';
@@ -150,7 +151,8 @@ class WebSocketManager {
// A live card drag owns the main thread: WS-driven renders mid-drag are what made dragging a
// working agent feel laggy, and nobody reads streaming tokens while holding a card. Buffer until
// the pointer settles, hard-capped by time and queue depth.
if (document.body.classList.contains('dashboard-marquee-active')) {
// A canvas pan or zoom owns the frame just as much as a card drag does, and had no hold at all.
if (document.body.classList.contains('dashboard-marquee-active') || (!perfBaseline() && interactionActive())) {
if (!WebSocketManager._dragDeferredAt) WebSocketManager._dragDeferredAt = Date.now();
if (Date.now() - WebSocketManager._dragDeferredAt < 2000 && WebSocketManager._messageQueue.length < 500) {
WebSocketManager._flushScheduled = true;
@@ -222,10 +224,45 @@ class WebSocketManager {
// treats as acceptable, so this cannot be worse than what a hidden chat already pays.
const heldTooLong = this.bgHoldSince !== null && Date.now() - this.bgHoldSince >= BG_MAX_HOLD_MS;
if (!this.backgrounded && interactionActive() && !heldTooLong) { this.armBgFlush(); return; }
// Every open chat hits the ceiling in the same tick, so their held streams used to land as one task of several synchronous renders (200-270 ms mid-pan, measured 2026-09-02). One chat per animation frame keeps the ceiling and spreads the cost.
if (!this.backgrounded && interactionActive() && !perfBaseline()) { WebSocketManager.spreadFlush(this); return; }
this.flushBgDelta();
}, delay);
}
private static _spreadQueue: WebSocketManager[] = [];
private static _spreadScheduled = false;
private static _spreadTimer: ReturnType<typeof setTimeout> | null = null;
private static spreadFlush(mgr: WebSocketManager) {
if (!WebSocketManager._spreadQueue.includes(mgr)) WebSocketManager._spreadQueue.push(mgr);
WebSocketManager.scheduleSpreadDrain();
}
private static scheduleSpreadDrain() {
if (WebSocketManager._spreadScheduled) return;
WebSocketManager._spreadScheduled = true;
requestAnimationFrame(WebSocketManager._drainSpread);
// rAF never fires while the window paints no frames; the timer keeps a held stream from stalling there.
WebSocketManager._spreadTimer = setTimeout(WebSocketManager._drainSpread, 300);
}
private static _drainSpread = () => {
if (!WebSocketManager._spreadScheduled) return;
WebSocketManager._spreadScheduled = false;
if (WebSocketManager._spreadTimer !== null) {
clearTimeout(WebSocketManager._spreadTimer);
WebSocketManager._spreadTimer = null;
}
const mgr = WebSocketManager._spreadQueue.shift();
if (mgr) mgr.flushBgDelta();
// One chat per frame turned a single hitch into a run of them on a short zoom (pass 2, 2026-09-02); one chat per quarter second keeps the ceiling and leaves the frames between them to the gesture.
if (WebSocketManager._spreadQueue.length > 0) {
WebSocketManager._spreadScheduled = true;
WebSocketManager._spreadTimer = setTimeout(WebSocketManager._drainSpread, 250);
}
};
private flushBgDelta() {
if (this.bgFlushTimer !== null) {
clearTimeout(this.bgFlushTimer);
@@ -0,0 +1,35 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
// Every open chat's held stream used to hit the one-second ceiling in the same tick and land as ONE task
// of several synchronous React renders (200-270 ms inside a pan, measured 2026-09-02 under eight agents).
// The drain takes one manager per animation frame, keeps a timer fallback for windows that paint no
// frames, and only ever runs while a gesture is live; the baseline seam restores the old single flush.
const ws = fs.readFileSync(path.join(process.cwd(), 'src/shared/ws/WebSocketManager.ts'), 'utf8');
const chat = fs.readFileSync(path.join(process.cwd(), 'src/app/pages/AgentChat/AgentChat.tsx'), 'utf8');
test('a held stream past the ceiling is spread one manager per frame, never flushed in the same tick', () => {
const arm = ws.slice(ws.indexOf('private armBgFlush()'), ws.indexOf('private static _spreadQueue'));
const spread = arm.indexOf('WebSocketManager.spreadFlush(this)');
const direct = arm.indexOf('this.flushBgDelta();');
assert.ok(spread > 0 && direct > spread, 'the spread path is checked BEFORE the direct flush');
assert.match(arm, /interactionActive\(\) && !perfBaseline\(\)/, 'spreading applies only mid-gesture and only off the baseline seam');
const drain = ws.slice(ws.indexOf('private static _drainSpread'), ws.indexOf('connect() {'));
assert.match(drain, /_spreadQueue\.shift\(\)/, 'one manager per drain');
assert.match(drain, /setTimeout\(WebSocketManager\._drainSpread, 250\)/, 'the rest wait a quarter second, not the next frame (one chat per frame read as a run of hitches on a short zoom)');
assert.match(ws.slice(ws.indexOf('private static scheduleSpreadDrain'), ws.indexOf('private static _drainSpread')), /setTimeout\(WebSocketManager\._drainSpread, 300\)/, 'a timer covers a window that paints no frames');
});
test('transcript heights are measured on mounted-set changes and resizes, never on every commit or mid-gesture', () => {
// The transcript has an older ResizeObserver above this effect (auto-follow), so every index starts at the effect itself.
const start = chat.indexOf('const measuredIdsRef');
const observerAt = chat.indexOf('new ResizeObserver', start);
const effect = chat.slice(start, observerAt);
assert.match(effect, /mountedIdsKey === measuredIdsRef\.current \|\| interactionActive\(\)/, 'skips an unchanged set and a live gesture');
assert.match(effect, /if \(perfBaseline\(\)\) \{ measureWindowItems\(\); return; \}/, 'the seam restores measure-every-commit');
const observer = chat.slice(observerAt, chat.indexOf('observer.disconnect()', observerAt));
assert.match(observer, /if \(interactionActive\(\) \|\| raf !== null\) return;/, 'a resize mid-gesture waits too');
assert.match(observer, /requestAnimationFrame/, 'resizes measure after paint, forcing nothing');
});