[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
@@ -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');
});