[eric] canvas lag optimizations

This commit is contained in:
ciregenz
2026-05-14 22:27:45 -07:00
parent abf4f9ba0b
commit 09e0174139
8 changed files with 138 additions and 248 deletions
@@ -153,7 +153,19 @@ const RichPromptEditor: React.FC<RichPromptEditorProps> = ({
}
}, []);
// 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<RichPromptEditorProps> = ({
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 (
@@ -614,7 +614,10 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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';
+35 -11
View File
@@ -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<string, string>();
// 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<string, ReturnType<typeof setTimeout>>();
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 !== '<br>') _draftStore.set(ownerId, html);
else _draftStore.delete(ownerId);
}, DRAFT_DEBOUNCE_MS));
}
const ICON_MAP: Record<string, React.ReactNode> = {
smart_toy: <SmartToyOutlinedIcon sx={{ fontSize: 14 }} />,
@@ -925,20 +940,26 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ 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 !== '<br>') {
_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<ChatInputHandle, Props>(({ 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) => {
@@ -150,4 +150,17 @@ const MessageActionBar: React.FC<Props> = ({
);
};
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
));
+35 -8
View File
@@ -237,13 +237,9 @@ const HANDLE_DEFS: { dir: ResizeDir; sx: Record<string, any> }[] = [
// 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<OuterProps, 'sessionId'> {
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<Props> = ({
);
};
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<OuterProps> = (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 (
<MemoAgentCard
{...props}
session={session}
cardX={cardEntry.x}
cardY={cardEntry.y}
cardWidth={cardEntry.width}
cardHeight={cardEntry.height}
cardZOrder={cardEntry.zOrder ?? 0}
/>
);
};
export default AgentCardOuter;
+14 -20
View File
@@ -1922,16 +1922,15 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
)}
<AnimatePresence>
{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<DashboardProps> = ({ 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<DashboardProps> = ({ dashboardId, isActive = true
return (
<AgentCard
key={session.id}
session={session}
expanded={expandedSessionIds.includes(session.id)}
cardX={card.x}
cardY={card.y}
cardWidth={card.width}
cardHeight={card.height}
cardZOrder={card.zOrder ?? 0}
key={sid}
sessionId={sid}
expanded={expandedSessionIds.includes(sid)}
zoom={canvas.zoom}
panX={canvas.panX}
panY={canvas.panY}
spawnFrom={origin}
exitTarget={exitTarget}
isSelected={selection.isSelected(session.id)}
isHighlighted={highlightedCardId === session.id}
isSelected={selection.isSelected(sid)}
isHighlighted={highlightedCardId === sid}
multiDragDelta={multiDragDelta}
onCardSelect={handleCardSelect}
onDragStart={handleCardDragStart}
@@ -1999,10 +1993,10 @@ const DashboardInner: React.FC<DashboardProps> = ({ 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}
/>
);
})}
+8 -8
View File
@@ -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';
+12 -198
View File
@@ -120,53 +120,6 @@ class WebSocketManager {
private outboundQueue: QueuedFrame[] = [];
private listeners: Map<string, Set<(data: any) => 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<string, {
sessionId: string;
messageId: string;
targetText: string;
displayedLength: number;
firstDeltaAt: number;
lastDeltaAt: number;
lastPaintAt: number;
measuredCps: number;
}> = 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,