mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-28 12:34:50 +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
@@ -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