mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-05 17:27:42 +02:00
[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:
co-authored by
Claude Fable 5.1
parent
461364789a
commit
adbccd7e04
@@ -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, '');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user