From 09e01741397eb51a742ca3f1cce1da9a2514eafb Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 14 May 2026 22:27:45 -0700 Subject: [PATCH] [eric] canvas lag optimizations --- .../src/app/components/RichPromptEditor.tsx | 17 +- .../src/app/pages/AgentChat/AgentChat.tsx | 5 +- .../src/app/pages/AgentChat/ChatInput.tsx | 46 +++- .../app/pages/AgentChat/MessageActionBar.tsx | 15 +- .../src/app/pages/Dashboard/AgentCard.tsx | 43 +++- .../src/app/pages/Dashboard/Dashboard.tsx | 34 ++- frontend/src/app/pages/Settings/Settings.tsx | 16 +- frontend/src/shared/ws/WebSocketManager.ts | 210 +----------------- 8 files changed, 138 insertions(+), 248 deletions(-) diff --git a/frontend/src/app/components/RichPromptEditor.tsx b/frontend/src/app/components/RichPromptEditor.tsx index 63374cea..dd003cf7 100644 --- a/frontend/src/app/components/RichPromptEditor.tsx +++ b/frontend/src/app/components/RichPromptEditor.tsx @@ -153,7 +153,19 @@ const RichPromptEditor: React.FC = ({ } }, []); + // See ChatInput.handleInput: paste skips the heavy DOM scans that + // paste can't invalidate (never adds skill pills, never starts a + // slash/at trigger). emitChange still runs because Modes settings + // is controlled and the parent needs the new value. + const justPastedRef = useRef(false); + const handleInput = useCallback(() => { + if (justPastedRef.current) { + justPastedRef.current = false; + setHasContent(true); + emitChange(); + return; + } updateHasContent(); detectTrigger(); syncAttachedSkills(); @@ -236,7 +248,10 @@ const RichPromptEditor: React.FC = ({ const handlePaste = useCallback((e: React.ClipboardEvent) => { e.preventDefault(); const plain = e.clipboardData.getData('text/plain'); - if (plain) document.execCommand('insertText', false, plain); + if (plain) { + justPastedRef.current = true; + document.execCommand('insertText', false, plain); + } }, []); return ( diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 1cbc164e..12bcb981 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -614,7 +614,10 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose } const used = Math.round(totalChars / 4); return { used, limit }; - }, [activeBranchMessages, session?.system_prompt, session?.streamingMessage?.content, model, modelsByProvider]); + // Depending on streamingMessage.id (not .content) recomputes once per + // turn instead of per painted character. The header pct gauge would + // otherwise re-run a full-message length sum every animation frame. + }, [activeBranchMessages, session?.system_prompt, session?.streamingMessage?.id, model, modelsByProvider]); const sessionRunning = session?.status === 'running' || session?.status === 'waiting_approval'; diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx index 9f5d65d5..878d7272 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx @@ -131,6 +131,21 @@ export interface ChatInputHandle { // sessionId (or a fallback owner id). Stores the raw innerHTML of the // contentEditable div so skill pills, formatting, etc. are preserved. const _draftStore = new Map(); +// Debounce per-owner so we don't read innerHTML (full DOM serialization) +// on every keystroke. ~200ms is below human "I expected my draft saved" +// while still coalescing fast typing and giant pastes into one read. +const _draftDebounceTimers = new Map>(); +const DRAFT_DEBOUNCE_MS = 200; +function scheduleDraftSave(ownerId: string, getHtml: () => string) { + const existing = _draftDebounceTimers.get(ownerId); + if (existing) clearTimeout(existing); + _draftDebounceTimers.set(ownerId, setTimeout(() => { + _draftDebounceTimers.delete(ownerId); + const html = getHtml(); + if (html && html !== '
') _draftStore.set(ownerId, html); + else _draftStore.delete(ownerId); + }, DRAFT_DEBOUNCE_MS)); +} const ICON_MAP: Record = { smart_toy: , @@ -925,20 +940,26 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, } }, []); + // Set in handlePaste before execCommand fires the synthetic input event, + // so handleInput can skip the heavy post-input scans that paste can't + // possibly invalidate (paste never adds skill pills, never starts a + // slash/at trigger sequence, and the content is non-empty by definition). + const justPastedRef = useRef(false); + const handleInput = useCallback(() => { + if (justPastedRef.current) { + // Fast path for paste: set hasContent without scanning textContent, + // skip detectTrigger / syncAttachedSkills, and defer the heavy + // innerHTML draft serialization. The flag is one-shot. + justPastedRef.current = false; + setHasContent(true); + scheduleDraftSave(ownerId, () => editorRef.current?.innerHTML ?? ''); + return; + } updateHasContent(); detectTrigger(); syncAttachedSkills(); - // Persist draft so it survives unmount/remount (card collapse, navigation) - const editor = editorRef.current; - if (editor) { - const html = editor.innerHTML; - if (html && html !== '
') { - _draftStore.set(ownerId, html); - } else { - _draftStore.delete(ownerId); - } - } + scheduleDraftSave(ownerId, () => editorRef.current?.innerHTML ?? ''); }, [updateHasContent, detectTrigger, syncAttachedSkills, ownerId]); const handleEditorClick = useCallback(() => { @@ -1092,7 +1113,10 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, } e.preventDefault(); const plain = e.clipboardData.getData('text/plain'); - if (plain) document.execCommand('insertText', false, plain); + if (plain) { + justPastedRef.current = true; + document.execCommand('insertText', false, plain); + } }, [addImageFiles, elementSelection, ownerId]); const handleDragOver = useCallback((e: React.DragEvent) => { diff --git a/frontend/src/app/pages/AgentChat/MessageActionBar.tsx b/frontend/src/app/pages/AgentChat/MessageActionBar.tsx index 5ff19921..80960c84 100644 --- a/frontend/src/app/pages/AgentChat/MessageActionBar.tsx +++ b/frontend/src/app/pages/AgentChat/MessageActionBar.tsx @@ -150,4 +150,17 @@ const MessageActionBar: React.FC = ({ ); }; -export default MessageActionBar; +// Action bar callbacks are inline arrow functions from AgentChat (closing +// over the per-message msg.id), so default memo equality fails on every +// parent render. Compare by callback presence + branch-nav primitives +// instead. The closures themselves are stable in EFFECT because they're +// keyed off msg.id, which is invariant for a given MessageBubble. +export default React.memo(MessageActionBar, (prev, next) => ( + prev.role === next.role + && !!prev.onCopy === !!next.onCopy + && !!prev.onEdit === !!next.onEdit + && !!prev.onRegenerate === !!next.onRegenerate + && !!prev.onBranch === !!next.onBranch + && prev.branchNav?.currentIndex === next.branchNav?.currentIndex + && prev.branchNav?.totalBranches === next.branchNav?.totalBranches +)); diff --git a/frontend/src/app/pages/Dashboard/AgentCard.tsx b/frontend/src/app/pages/Dashboard/AgentCard.tsx index 71abc7c3..69b9b01d 100644 --- a/frontend/src/app/pages/Dashboard/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/AgentCard.tsx @@ -237,13 +237,9 @@ const HANDLE_DEFS: { dir: ResizeDir; sx: Record }[] = [ // AgentCard // --------------------------------------------------------------------------- -interface Props { - session: AgentSession; +interface OuterProps { + sessionId: string; expanded: boolean; - cardX: number; - cardY: number; - cardWidth: number; - cardHeight: number; zoom?: number; panX?: number; panY?: number; @@ -260,12 +256,20 @@ interface Props { onMeasuredHeight?: (sessionId: string, height: number) => void; snapColumn?: { x: number; width: number }; autoFocusInput?: boolean; - cardZOrder?: number; onDoubleClick?: (id: string, type: 'agent' | 'view' | 'browser') => void; onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser') => void; shakeDirection?: 'left' | 'right' | 'up' | 'down' | null; } +interface Props extends Omit { + session: AgentSession; + cardX: number; + cardY: number; + cardWidth: number; + cardHeight: number; + cardZOrder: number; +} + const MIN_W = 480; const MIN_H = 120; const EXPANDED_OVERLAY_H = 620; @@ -1184,4 +1188,27 @@ const AgentCard: React.FC = ({ ); }; -export default React.memo(AgentCard); +const MemoAgentCard = React.memo(AgentCard); + +// Self-subscribing outer: this is what Dashboard renders. Each card reads +// only its own session + card position from Redux, so a streamDelta to +// session A no longer disturbs B's props. Dashboard's iteration just hands +// down sessionId + cross-card UI state (selection, drag, glow). +const AgentCardOuter: React.FC = (props) => { + const session = useAppSelector((s) => s.agents.sessions[props.sessionId]); + const cardEntry = useAppSelector((s) => s.dashboardLayout.cards[props.sessionId]); + if (!session || !cardEntry) return null; + return ( + + ); +}; + +export default AgentCardOuter; diff --git a/frontend/src/app/pages/Dashboard/Dashboard.tsx b/frontend/src/app/pages/Dashboard/Dashboard.tsx index 9d268080..d169ba97 100644 --- a/frontend/src/app/pages/Dashboard/Dashboard.tsx +++ b/frontend/src/app/pages/Dashboard/Dashboard.tsx @@ -1922,16 +1922,15 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true )} {Object.values(cards).map((card) => { - const session = sessions[card.session_id]; - if (!session) return null; + const sid = card.session_id; - let origin = spawnOriginsRef.current[session.id]; + let origin = spawnOriginsRef.current[sid]; if (origin) { - delete spawnOriginsRef.current[session.id]; + delete spawnOriginsRef.current[sid]; } else { - const glow = glowingAgentCards[session.id]; - if (glow && !revealSpawnedRef.current.has(session.id)) { - revealSpawnedRef.current.add(session.id); + const glow = glowingAgentCards[sid]; + if (glow && !revealSpawnedRef.current.has(sid)) { + revealSpawnedRef.current.add(sid); const srcCard = cards[glow.sourceId]; if (srcCard) { const srcH = measuredHeightsRef.current[glow.sourceId] @@ -1948,7 +1947,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true } let exitTarget: { x: number; y: number } | undefined; - const glow = glowingAgentCards[session.id]; + const glow = glowingAgentCards[sid]; if (glow) { const srcCard = cards[glow.sourceId]; if (srcCard) { @@ -1976,21 +1975,16 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true return ( = ({ dashboardId, isActive = true onBranch={handleBranchFromCard} onMeasuredHeight={handleMeasuredHeight} snapColumn={snapColumn} - autoFocusInput={autoFocusSessionId === session.id} + autoFocusInput={autoFocusSessionId === sid} onDoubleClick={handleCardDoubleClick} onBringToFront={handleBringToFront} - shakeDirection={focusedCardId === session.id ? shakeDirection : null} + shakeDirection={focusedCardId === sid ? shakeDirection : null} /> ); })} diff --git a/frontend/src/app/pages/Settings/Settings.tsx b/frontend/src/app/pages/Settings/Settings.tsx index 6d481ea9..d6d72ff0 100644 --- a/frontend/src/app/pages/Settings/Settings.tsx +++ b/frontend/src/app/pages/Settings/Settings.tsx @@ -213,16 +213,16 @@ const clampPickerPlan = (plan: string | null | undefined): OpenSwarmPlan => { const AccountCard: React.FC = () => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); - const settings = useAppSelector((s) => s.settings.data); + // Narrow selectors: each subscribes to one primitive so unrelated + // settings edits (e.g. theme toggle) don't re-render this card. + const userEmail = useAppSelector((s) => s.settings.data.user_email ?? null); + const userId = useAppSelector((s) => s.settings.data.user_id ?? null); + const signinMethod = useAppSelector((s) => s.settings.data.signin_method ?? null); + const hasBearer = useAppSelector((s) => Boolean(s.settings.data.openswarm_bearer_token)); + const installId = useAppSelector((s) => s.settings.data.installation_id ?? ''); + const proxyUrl = useAppSelector((s) => s.settings.data.openswarm_proxy_url || OPENSWARM_DEFAULT_PROXY_URL); const [signingOut, setSigningOut] = useState(false); - const userEmail = settings.user_email ?? null; - const userId = settings.user_id ?? null; - const signinMethod = settings.signin_method ?? null; - const hasBearer = Boolean(settings.openswarm_bearer_token); - const installId = settings.installation_id ?? ''; - const proxyUrl = settings.openswarm_proxy_url || OPENSWARM_DEFAULT_PROXY_URL; - const methodLabel = (() => { switch (signinMethod) { case 'google': return 'Signed in with Google'; diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 4e8c6a99..0287290c 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -120,53 +120,6 @@ class WebSocketManager { private outboundQueue: QueuedFrame[] = []; private listeners: Map void>> = new Map(); - // Per-message streaming state. Rate-based pacing tracks measured - // throughput so paint output is smooth even when the server emits in - // bursts (which Anthropic / 9Router / OS TCP all do). Each frame we - // paint a small uniform chunk sized so that we'd drain the backlog - // over the next ~burstWindowMs — when the next burst arrives, we - // adjust without ever going dry between bursts. - // - // Fields: - // firstDeltaAt: timestamp of the very first delta. Used to compute - // average chars/sec over the lifetime of the stream. - // lastPaintAt: when we last actually dispatched. Frame loop reads - // this to enforce minimum step-time even when RAF fires faster - // than we want. - // measuredCps: rolling chars-per-second estimate. Decays on idle so - // a fast burst doesn't permanently inflate the rate. - // underrunMs: how long we've been "caught up" (no backlog) since - // the last paint. Used to detect we're rate-limited by the - // server, not by our cadence — when this gets large, we slow - // down to leave headroom for the next burst. - private interpolatorState: Map = new Map(); - private interpolatorRafId: number | null = null; - // Initial paint delay (ms). We hold the first delta briefly so - // an inter-burst gap can land before painting starts. Without it - // the very first frame paints aggressively, then idles waiting - // for the next server burst — visible as a tiny boom-pause at - // the start of every stream. Imperceptible to humans (saccades - // run at ~250ms, well above this). - private static INITIAL_HOLD_MS = 150; - // Paint cadence in ms. ~30Hz — well above perceptual flicker, - // light enough on React reconciliation that it stays smooth on - // long messages. - private static PAINT_INTERVAL_MS = 33; - // Target painting throughput. 10 chars per 33ms = ~300 cps — - // the "fast comfortable typing" visual rate (20% slower than the - // previous 400 cps default). Still well above natural reading - // speed (~200 cps comfort threshold), still hides bursty upstream - // cadence, just feels less frantic. Tuned for legibility at speed. - private static TARGET_CHARS_PER_PAINT = 10; // Frame-aligned message coalescer. Buffers incoming WS messages from // all WebSocketManager instances and flushes them in ONE batched // React render per animation frame. Without this, N concurrent agents @@ -204,18 +157,6 @@ class WebSocketManager { }); }; - // When a backlog accumulates, allow up to this many chars/paint to - // drain it. ~1.6× the target keeps catch-up imperceptible — the - // eye can't tell 10 from 16 in a fluid stream. Caps the worst-case - // visual jump on a giant burst. - private static MAX_CHARS_PER_PAINT = 16; - // Headroom buffer in ms. We try to keep at least this much "future - // paintable" content on hand at all times, so the next upstream - // burst can be coalesced into the visible stream without a pause. - // Adds a fixed latency budget — humans don't notice anything below - // ~250ms in continuous text, so 200ms is well-tuned. - private static HEADROOM_MS = 200; - constructor(url: string, options?: WSManagerOptions) { this.url = url; this.skipStreamEvents = options?.skipStreamEvents ?? false; @@ -232,132 +173,12 @@ class WebSocketManager { } } - private bufferDelta(sessionId: string, messageId: string, delta: string) { - const now = performance.now(); - const existing = this.interpolatorState.get(messageId); - if (existing) { - existing.targetText += delta; - existing.lastDeltaAt = now; - } else { - this.interpolatorState.set(messageId, { - sessionId, - messageId, - targetText: delta, - displayedLength: 0, - firstDeltaAt: now, - lastDeltaAt: now, - // Seed lastPaintAt INITIAL_HOLD_MS in the future so the first - // tick won't paint until that delay has passed — gives the - // upstream a chance to land more bytes before we start, so - // we don't underrun on the very first frame. - lastPaintAt: now + WebSocketManager.INITIAL_HOLD_MS, - measuredCps: 0, - }); - } - this.scheduleInterpolator(); - } - - private scheduleInterpolator() { - if (this.interpolatorRafId != null) return; - // Schedule on every frame — the time-throttle inside tickInterpolator - // decides whether this frame actually paints. RAF gives us frame- - // synced timing without the overhead of setInterval drift, and the - // throttle ensures we only dispatch once per PAINT_INTERVAL_MS even - // if RAF fires more often (which it does on 120Hz displays). - this.interpolatorRafId = requestAnimationFrame(() => this.tickInterpolator()); - } - - // Fixed-rate "extremely fast typing" pacing. Paints at a constant - // ~400 cps target regardless of upstream burstiness. The buffer - // grows when bursts land above target and drains during gaps — - // because most models stream below 400 cps on average, we keep up - // easily and the user sees smooth, uniform high-speed typing. No - // more boom-pause-boom: the buffer absorbs bursts and the constant - // paint rate hides them. - // - // Three behaviors: - // 1. Healthy backlog (>= TARGET): paint exactly TARGET chars. - // 2. Big backlog (more than HEADROOM_MS-worth queued): paint up - // to MAX to slowly catch up. Capped low enough that the - // acceleration is invisible. - // 3. Underflow (less than TARGET remaining, stream still active): - // paint everything we have at the cadence and pause. Better - // than artificially trickling — the natural pause is short - // because the next burst from the server fills the buffer - // again. - // - // Latency cost: HEADROOM_MS (~200ms) behind real time. Imperceptible. - private tickInterpolator() { - this.interpolatorRafId = null; - const now = performance.now(); - let workRemaining = false; - for (const state of this.interpolatorState.values()) { - const remaining = state.targetText.length - state.displayedLength; - if (remaining <= 0) continue; - // Time-throttle: paint once per PAINT_INTERVAL_MS regardless of - // display refresh rate. The lastPaintAt was seeded with - // `now + INITIAL_HOLD_MS` in bufferDelta on first delta, so the - // first frame is naturally delayed. - const sincePaint = now - state.lastPaintAt; - if (sincePaint < WebSocketManager.PAINT_INTERVAL_MS) { - workRemaining = true; - continue; - } - // Headroom in ms = remaining / TARGET_CPS. If we have more than - // HEADROOM_MS of paintable content queued, drain slightly faster - // to bound visible latency. Otherwise paint at the steady target - // rate. - const targetCps = WebSocketManager.TARGET_CHARS_PER_PAINT * (1000 / WebSocketManager.PAINT_INTERVAL_MS); - const headroomMs = (remaining / targetCps) * 1000; - let step: number; - if (headroomMs > WebSocketManager.HEADROOM_MS * 2) { - // Big buffer — accelerate slightly to catch up. Bounded so - // the visible flow doesn't become unstably variable. - step = WebSocketManager.MAX_CHARS_PER_PAINT; - } else { - // Steady-state: paint exactly TARGET. This is the "fast - // typing" cadence that hides upstream bursts. - step = WebSocketManager.TARGET_CHARS_PER_PAINT; - } - // Don't paint past the end of the buffered text. When this - // shrinks the step, we're underflowing — the natural pause that - // follows is exactly what we want (better than trickling fake- - // slow chars). The next upstream burst will land and we'll - // resume painting at TARGET. - step = Math.min(step, remaining); - const nextLength = state.displayedLength + step; - const deltaSlice = state.targetText.slice(state.displayedLength, nextLength); - state.displayedLength = nextLength; - state.lastPaintAt = now; - store.dispatch(streamDelta({ - sessionId: state.sessionId, - messageId: state.messageId, - delta: deltaSlice, - })); - if (state.displayedLength < state.targetText.length) workRemaining = true; - } - if (workRemaining) this.scheduleInterpolator(); - } - - // Flush remaining pending text synchronously. Pass a messageId to flush - // only that stream (used on stream_end so the tail isn't paced). - private flushInterpolator(messageId?: string) { - const drain = (state: { sessionId: string; messageId: string; targetText: string; displayedLength: number }) => { - if (state.displayedLength >= state.targetText.length) return; - const tail = state.targetText.slice(state.displayedLength); - state.displayedLength = state.targetText.length; - store.dispatch(streamDelta({ sessionId: state.sessionId, messageId: state.messageId, delta: tail })); - }; - if (messageId) { - const state = this.interpolatorState.get(messageId); - if (state) { - drain(state); - this.interpolatorState.delete(messageId); - } - } else { - for (const state of this.interpolatorState.values()) drain(state); - this.interpolatorState.clear(); - } + // Tokens render as they arrive (claude.ai feel). Per-frame WS batching + // in _enqueueMessage still coalesces N concurrent agents' messages into + // ONE React render per animation frame, so removing the pacing layer + // doesn't reintroduce the parallel-agent re-render storm. + private dispatchDelta(sessionId: string, messageId: string, delta: string) { + store.dispatch(streamDelta({ sessionId, messageId, delta })); } connect() { @@ -403,9 +224,9 @@ class WebSocketManager { // render — dozens per frame, fanning out to every useSelector // subscriber, starving the main thread. Coalescing flips that // to ONE batched render per frame regardless of how many - // messages arrived. Stream-chunk dispatches are already paced - // by the interpolator, so this is purely additive throttling - // for non-stream events (status, tool_call, completion, etc). + // messages arrived. Stream deltas dispatch directly into Redux + // (no client-side pacing), so the typed-text rate matches what + // the server sends, the same way claude.ai feels. WebSocketManager._enqueueMessage(this, msg); } catch { // ignore malformed messages @@ -447,11 +268,6 @@ class WebSocketManager { this.reconnectTimer = null; } this.stopHeartbeat(); - if (this.interpolatorRafId != null) { - cancelAnimationFrame(this.interpolatorRafId); - this.interpolatorRafId = null; - } - this.flushInterpolator(); this.ws?.close(); this.ws = null; } @@ -670,7 +486,6 @@ class WebSocketManager { case 'agent:message': if (session_id && data.message) { - if (this.interpolatorState.size > 0) this.flushInterpolator(); store.dispatch(addMessage({ sessionId: session_id, message: data.message })); } break; @@ -684,8 +499,8 @@ class WebSocketManager { // because of `key={session.id}`), last_seq is 0, so the server // replays EVERY buffered stream_* event for the session. // Without this guard, opening any chat with prior streaming - // turns animates the entire history through the typewriter - // interpolator on every reopen. + // turns would replay every buffered delta as a live stream + // event, re-triggering the streaming UI on every reopen. // // The discriminator is `resumeAcked`: it flips to true when // server:hello arrives, which the server sends AFTER the replay @@ -706,11 +521,10 @@ class WebSocketManager { } } else if (event === 'agent:stream_delta') { if (session_id && data.message_id) { - this.bufferDelta(session_id, data.message_id, data.delta); + this.dispatchDelta(session_id, data.message_id, data.delta); } } else if (event === 'agent:stream_end') { if (session_id && data.message_id) { - this.flushInterpolator(data.message_id); store.dispatch(streamEnd({ sessionId: session_id, messageId: data.message_id,