[eric] streaming: mid-gesture deltas ride the 1Hz buffer with a 250ms hand-off flush, so token commits stop landing inside drags (ENG-301)

This commit is contained in:
ciregenz
2026-08-18 11:34:28 -07:00
parent 09f483ec8c
commit a98a99cbab
3 changed files with 48 additions and 2 deletions
@@ -0,0 +1,15 @@
// ENG-301: mid-gesture stream deltas must yield to the hand. Pins the decay contract.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { markInteraction, interactionActive } from './interactionPriority';
test('quiet by default', () => {
assert.equal(interactionActive(), false);
});
test('active immediately after a gesture, decays after 350ms', async () => {
markInteraction();
assert.equal(interactionActive(), true);
await new Promise((r) => setTimeout(r, 400));
assert.equal(interactionActive(), false);
});
@@ -0,0 +1,24 @@
// While the user is mid-gesture (dragging a card, wheeling the canvas), streamed tokens must not
// steal frames: each delta lands as a React commit, and under several live agents those commits
// burst to 50-66ms right through the drag (measured on exp.13, the "still feels glitchy" report).
// Capture-phase listeners keep a decaying "interacting" stamp; stream dispatch consults it and
// falls back to the 1Hz buffer during the gesture. Smoothness beats token immediacy for ~a second.
const DECAY_MS = 350;
let p_lastInteraction = 0;
export function markInteraction(): void {
p_lastInteraction = performance.now();
}
export function interactionActive(): boolean {
return performance.now() - p_lastInteraction < DECAY_MS;
}
let p_installed = false;
export function installInteractionListeners(): void {
if (p_installed || typeof window === 'undefined') return;
p_installed = true;
window.addEventListener('wheel', markInteraction, { capture: true, passive: true });
window.addEventListener('pointerdown', markInteraction, { capture: true, passive: true });
window.addEventListener('pointermove', (e: PointerEvent) => { if (e.buttons) markInteraction(); }, { capture: true, passive: true });
}
+9 -2
View File
@@ -31,6 +31,7 @@ import {
} from '../state/agentsSlice';
import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice';
import { BackgroundDeltaBuffer } from './BackgroundDeltaBuffer';
import { interactionActive, installInteractionListeners } from '../interactionPriority';
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';
@@ -43,6 +44,7 @@ import { notifyAgentCompletion, notifyWorkflowRun } from '../notifications';
// Phase 0 boot instrumentation: one-shot flag so we report the first streamed agent token to Electron main exactly once per app launch. Module scope (not instance) because multiple WebSocketManagers exist (one per session WS).
let firstAgentResponseMarked = false;
installInteractionListeners();
// Thin wrapper around getAuthToken so the connect() call site stays synchronous. If the token isn't cached yet, returns '' and the WS handshake will 4401, onclose catches that and refreshes the token before the next reconnect.
const _getAuthTokenSafe = (): string => {
@@ -180,7 +182,9 @@ class WebSocketManager {
firstAgentResponseMarked = true;
try { (window as any).openswarm?.markFirstAgentResponse?.(); } catch { /* not in Electron */ }
}
if (this.backgrounded) {
// Mid-gesture, a delta rides the same 1Hz buffer as a backgrounded chat: its React commit
// (50-66ms under load) would land inside the user's drag frames (ENG-301 "still glitchy").
if (this.backgrounded || interactionActive()) {
const evicted = this.bgBuffer.add(messageId, delta);
if (evicted) store.dispatch(streamDelta({ sessionId, messageId: evicted.messageId, delta: evicted.text }));
this.armBgFlush();
@@ -198,10 +202,13 @@ class WebSocketManager {
private armBgFlush() {
if (this.bgFlushTimer !== null) return;
// Gesture-buffered deltas flush fast (250ms) once the hand stops; true background stays 1Hz.
const delay = this.backgrounded ? 1000 : 250;
this.bgFlushTimer = setTimeout(() => {
this.bgFlushTimer = null;
if (!this.backgrounded && interactionActive()) { this.armBgFlush(); return; }
this.flushBgDelta();
}, 1000);
}, delay);
}
private flushBgDelta() {