diff --git a/frontend/src/shared/interactionPriority.test.ts b/frontend/src/shared/interactionPriority.test.ts new file mode 100644 index 00000000..160ffa50 --- /dev/null +++ b/frontend/src/shared/interactionPriority.test.ts @@ -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); +}); diff --git a/frontend/src/shared/interactionPriority.ts b/frontend/src/shared/interactionPriority.ts new file mode 100644 index 00000000..eacd707d --- /dev/null +++ b/frontend/src/shared/interactionPriority.ts @@ -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 }); +} diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 4d91ee12..1cdd37e6 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -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() {