diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 137c77ae..0e809c2a 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -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 = ({ 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 = ({ 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('[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 = ({ 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', }} > diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 18a11a82..4d903bc6 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -437,6 +437,9 @@ const AgentCard: React.FC = ({ 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 = ({ 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} /> ) : ( diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index b1a14e42..b618a629 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -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 = ({ 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 | 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(); diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/gestureStartCosts.test.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/gestureStartCosts.test.ts new file mode 100644 index 00000000..4be99116 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/gestureStartCosts.test.ts @@ -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'); +}); diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index 23a8b9c6..ee2ffaed 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -89,7 +89,11 @@ export function useCanvasControls( const gridRef = useRef(null); const [state, setState] = useState({ 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(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, diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts index 2a485010..24162f21 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts @@ -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 { - // 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((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((resolve) => encodeShotWhenIdle(image, SNAPSHOT_MAX_W, resolve)); } catch { return ''; } diff --git a/frontend/src/shared/encodeShotWhenIdle.test.ts b/frontend/src/shared/encodeShotWhenIdle.test.ts new file mode 100644 index 00000000..3b1d39f7 --- /dev/null +++ b/frontend/src/shared/encodeShotWhenIdle.test.ts @@ -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, ''); +}); diff --git a/frontend/src/shared/encodeShotWhenIdle.ts b/frontend/src/shared/encodeShotWhenIdle.ts new file mode 100644 index 00000000..dd2d8179 --- /dev/null +++ b/frontend/src/shared/encodeShotWhenIdle.ts @@ -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); +} diff --git a/frontend/src/shared/perfBaseline.ts b/frontend/src/shared/perfBaseline.ts new file mode 100644 index 00000000..51f37d6b --- /dev/null +++ b/frontend/src/shared/perfBaseline.ts @@ -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; +} diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 0cf17700..26afeed0 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -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 | 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); diff --git a/frontend/src/shared/ws/spreadFlush.test.ts b/frontend/src/shared/ws/spreadFlush.test.ts new file mode 100644 index 00000000..7da1a4f4 --- /dev/null +++ b/frontend/src/shared/ws/spreadFlush.test.ts @@ -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'); +});