mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 04:37:44 +02:00
[eric] canvas lag optimizations
This commit is contained in:
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user