mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-09 11:17:44 +02:00
[eric] chat: 60fps reveal via imperative tail appends, markdown re-parses per commit not per frame; drop dead 250ms ticker
This commit is contained in:
@@ -553,7 +553,8 @@ const ThinkingBubble: React.FC<{
|
||||
persistedToolCount?: number;
|
||||
// Aux-LLM label like "Auditing the pull request"; null falls back to heuristic.
|
||||
dynamicLabel?: string | null;
|
||||
}> = ({ content, isStreaming, messageId, persistedElapsedMs, persistedTokens, persistedInputTokens, persistedToolCount, dynamicLabel }) => {
|
||||
revealRef?: React.RefObject<HTMLElement | null>;
|
||||
}> = ({ content, isStreaming, messageId, persistedElapsedMs, persistedTokens, persistedInputTokens, persistedToolCount, dynamicLabel, revealRef }) => {
|
||||
const c = useClaudeTokens();
|
||||
|
||||
const turnLabel = useMemo(
|
||||
@@ -564,7 +565,6 @@ const ThinkingBubble: React.FC<{
|
||||
const [startedStreamingAt, setStartedStreamingAt] = useState<number | null>(
|
||||
isStreaming ? Date.now() : null
|
||||
);
|
||||
const [elapsed, setElapsed] = useState<number>(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isStreaming && startedStreamingAt === null) {
|
||||
@@ -572,14 +572,6 @@ const ThinkingBubble: React.FC<{
|
||||
}
|
||||
}, [isStreaming, startedStreamingAt]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isStreaming || startedStreamingAt === null) return;
|
||||
const iv = setInterval(() => {
|
||||
setElapsed(Math.floor((Date.now() - startedStreamingAt) / 1000));
|
||||
}, 250);
|
||||
return () => clearInterval(iv);
|
||||
}, [isStreaming, startedStreamingAt]);
|
||||
|
||||
// userOverride pins explicit clicks; default is expanded while streaming, collapsed after.
|
||||
const [userOverride, setUserOverride] = useState<boolean | null>(null);
|
||||
const expanded = userOverride ?? !!isStreaming;
|
||||
@@ -774,7 +766,7 @@ const ThinkingBubble: React.FC<{
|
||||
>
|
||||
{text ? (
|
||||
<>
|
||||
{text}
|
||||
<Box component="span" ref={revealRef}>{text}</Box>
|
||||
{isStreaming && <StreamingCursor />}
|
||||
</>
|
||||
) : (
|
||||
@@ -841,9 +833,11 @@ interface Props {
|
||||
onCancelEdit?: () => void;
|
||||
isStreaming?: boolean;
|
||||
dynamicTurnLabel?: string | null;
|
||||
/** Streaming only: useSmoothText appends revealed chars into this subtree between parses. */
|
||||
revealRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, onSaveEdit, onCancelEdit, isStreaming, dynamicTurnLabel }) => {
|
||||
const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, onSaveEdit, onCancelEdit, isStreaming, dynamicTurnLabel, revealRef }) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const [editText, setEditText] = useState('');
|
||||
@@ -865,6 +859,7 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
|
||||
<ThinkingBubble
|
||||
content={typeof content === 'string' ? content : JSON.stringify(content)}
|
||||
isStreaming={isStreaming}
|
||||
revealRef={revealRef}
|
||||
timestamp={message.timestamp}
|
||||
messageId={message.id}
|
||||
persistedElapsedMs={(message as any).elapsed_ms}
|
||||
@@ -1180,8 +1175,9 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
|
||||
bold is bold, lists/headings format from the first character.
|
||||
Killing the old plain-text -> markdown swap removes the big
|
||||
layout snap at stream end, which was the "glitch" people felt.
|
||||
Re-parse is memoized on the (smoothed) text and cheap at chat sizes. */}
|
||||
{renderedMarkdown}
|
||||
While streaming, useSmoothText appends pending chars into this
|
||||
subtree between parses, so the re-parse runs per commit, not per frame. */}
|
||||
<Box ref={revealRef}>{renderedMarkdown}</Box>
|
||||
{isStreaming && <StreamingCursor />}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -21,7 +21,7 @@ const StreamingBubble: React.FC<Props> = ({ sessionId, activeBranchId, turnLabel
|
||||
// same streaming slice, so smoothing here covers all of them at once. Tool-call
|
||||
// input is left raw (it's args, not prose). Zero added TTFT (see useSmoothText).
|
||||
const isTextRole = streamingMessage?.role !== 'tool_call';
|
||||
const smoothContent = useSmoothText(rawContent, isTextRole);
|
||||
const { text: smoothContent, revealRef } = useSmoothText(rawContent, isTextRole);
|
||||
const typedContent = isTextRole ? smoothContent : rawContent;
|
||||
// RAF-coalesce so onStreamGrew fires once per frame regardless of token rate.
|
||||
const onGrewRef = useRef(onStreamGrew);
|
||||
@@ -66,6 +66,7 @@ const StreamingBubble: React.FC<Props> = ({ sessionId, activeBranchId, turnLabel
|
||||
<MessageBubble
|
||||
key={`streaming-${streamingMessage.id}`}
|
||||
isStreaming
|
||||
revealRef={revealRef}
|
||||
dynamicTurnLabel={turnLabel}
|
||||
message={{
|
||||
id: streamingMessage.id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Smoothly reveals streamed text at a steady cadence instead of painting bursty
|
||||
@@ -6,32 +6,37 @@ import { useEffect, useRef, useState } from 'react';
|
||||
* claude.ai does, so generated text reads like it's being typed rather than
|
||||
* dumped in clumps.
|
||||
*
|
||||
* Why the old "reveal backlog/4, floor 3 chars/frame" version felt like
|
||||
* "pump pump pump": that floor (~180 chars/sec) is FASTER than a model
|
||||
* generates (~90 chars/sec), so the display kept sprinting to catch up, then
|
||||
* FROZE waiting for the next token. Freeze-sprint-freeze at token frequency is
|
||||
* the choppiness.
|
||||
*
|
||||
* This version is a buffered constant-velocity controller:
|
||||
* Velocity model (unchanged from v1): a buffered constant-velocity controller.
|
||||
* - It deliberately stays ~TARGET_LAG seconds BEHIND the latest text, so there
|
||||
* is always a buffer to reveal and it never runs dry between tokens.
|
||||
* - Reveal is TIME-based (chars = rate * elapsed), so it's frame-rate
|
||||
* independent and survives a dropped frame without a visible jump.
|
||||
* - The reveal RATE is EMA-smoothed, so a burst ramps the speed up gently and
|
||||
* a lull ramps it down gently; the rate never steps, so the flow never pulses.
|
||||
* The rAF loop runs only while there's a backlog and parks at zero cost once
|
||||
* caught up. Zero added TTFT: the first characters still reveal in-render on the
|
||||
* very first frame content exists.
|
||||
*
|
||||
* Render model (v2): the v1 hook setState'd every frame, which re-rendered the
|
||||
* whole bubble and re-parsed the full markdown tree 60x/s for the entire stream.
|
||||
* Now the 60fps motion comes from appending the pending characters straight into
|
||||
* the LAST DOM TEXT NODE under `revealRef` (one block relayout, no React), and
|
||||
* React only re-renders ("commits") when structure can change: every COMMIT_MS,
|
||||
* or immediately when the pending slice contains a newline (new block / list item
|
||||
* / fence line). Inline markers (** ` _) show raw for at most COMMIT_MS before
|
||||
* the parse formats them, which matches how unclosed markers already looked.
|
||||
*/
|
||||
|
||||
const TARGET_LAG_S = 0.35; // stay this far behind = the buffer that prevents stalls
|
||||
const RATE_SMOOTH_S = 0.25; // how fast the reveal speed eases toward its target
|
||||
const MAX_CPS = 1000; // cap so a huge paste/burst still reveals smoothly, not instantly
|
||||
const MAX_DT_S = 0.05; // clamp elapsed after a frame drop / tab switch so we don't leap
|
||||
const COMMIT_MS = 150; // max staleness of the parsed markdown vs the revealed chars
|
||||
|
||||
export function useSmoothText(
|
||||
target: string,
|
||||
enabled: boolean,
|
||||
): { text: string; revealRef: React.RefObject<HTMLElement | null> } {
|
||||
const [committedLen, setCommittedLen] = useState(enabled ? 0 : target.length);
|
||||
const revealRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
export function useSmoothText(target: string, enabled: boolean): string {
|
||||
const [shownLen, setShownLen] = useState(enabled ? 0 : target.length);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const targetRef = useRef(target);
|
||||
targetRef.current = target;
|
||||
|
||||
@@ -40,22 +45,53 @@ export function useSmoothText(target: string, enabled: boolean): string {
|
||||
const posRef = useRef<number>(enabled ? 0 : target.length); // float reveal position
|
||||
const cpsRef = useRef<number>(0); // current reveal speed
|
||||
const lastRef = useRef<number>(0); // last frame timestamp
|
||||
const shownRef = useRef<number>(shownLen);
|
||||
shownRef.current = shownLen;
|
||||
const committedRef = useRef<number>(committedLen);
|
||||
const lastCommitAtRef = useRef<number>(0);
|
||||
|
||||
// Imperative-tail bookkeeping: which committedLen the DOM reflects, and the
|
||||
// text node + its committed baseline that per-frame appends write into.
|
||||
const domLenRef = useRef<number>(committedLen);
|
||||
const nodeRef = useRef<Text | null>(null);
|
||||
const baseRef = useRef<string>('');
|
||||
|
||||
const findLastTextNode = (): Text | null => {
|
||||
const root = revealRef.current;
|
||||
if (!root) return null;
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
let last: Text | null = null;
|
||||
let n: Node | null;
|
||||
while ((n = walker.nextNode())) last = n as Text;
|
||||
return last;
|
||||
};
|
||||
|
||||
// After each committed render, re-anchor the tail on the fresh DOM and
|
||||
// re-apply any chars the reveal position is already past, so a commit never
|
||||
// rewinds visible text.
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) return;
|
||||
const node = findLastTextNode();
|
||||
nodeRef.current = node;
|
||||
baseRef.current = node ? node.data : '';
|
||||
domLenRef.current = committedLen;
|
||||
const shown = Math.floor(posRef.current);
|
||||
if (node && shown > committedLen) {
|
||||
node.data = baseRef.current + targetRef.current.slice(committedLen, shown);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [committedLen, enabled]);
|
||||
|
||||
// ONE persistent loop, keyed only on `enabled`. It must NOT restart per token:
|
||||
// an effect that depends on target.length tears the rAF down and rebuilds it on
|
||||
// every delta, and that churn is what stalls the reveal. So the loop runs every
|
||||
// frame for the life of the stream, reads the latest text from a ref, and just
|
||||
// advances by 0 when it happens to be caught up (cheap, no stall, no parking).
|
||||
// every delta, and that churn is what stalls the reveal.
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; }
|
||||
posRef.current = targetRef.current.length;
|
||||
setShownLen(targetRef.current.length);
|
||||
committedRef.current = targetRef.current.length;
|
||||
setCommittedLen(targetRef.current.length);
|
||||
return;
|
||||
}
|
||||
|
||||
let raf: number | null = null;
|
||||
const tick = (now: number) => {
|
||||
const full = targetRef.current.length;
|
||||
const dtRaw = lastRef.current ? (now - lastRef.current) / 1000 : 0.016;
|
||||
@@ -72,16 +108,29 @@ export function useSmoothText(target: string, enabled: boolean): string {
|
||||
|
||||
if (backlog > 0) {
|
||||
posRef.current = Math.min(full, posRef.current + cps * dt);
|
||||
const nextLen = Math.floor(posRef.current);
|
||||
if (nextLen !== shownRef.current) setShownLen(nextLen);
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick); // keep running for the whole stream
|
||||
const shown = Math.floor(posRef.current);
|
||||
const committed = committedRef.current;
|
||||
if (shown > committed) {
|
||||
const pending = targetRef.current.slice(committed, shown);
|
||||
const due = now - lastCommitAtRef.current >= COMMIT_MS;
|
||||
if (pending.includes('\n') || due || nodeRef.current === null) {
|
||||
committedRef.current = shown;
|
||||
lastCommitAtRef.current = now;
|
||||
setCommittedLen(shown);
|
||||
} else if (domLenRef.current === committed) {
|
||||
// DOM is in sync with the last commit; safe to append imperatively.
|
||||
nodeRef.current.data = baseRef.current + pending;
|
||||
}
|
||||
// else: a commit is mid-flight; skip this frame's append (≤1 frame).
|
||||
}
|
||||
raf = requestAnimationFrame(tick); // keep running for the whole stream
|
||||
};
|
||||
|
||||
lastRef.current = 0;
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; }
|
||||
if (raf != null) cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
@@ -92,15 +141,12 @@ export function useSmoothText(target: string, enabled: boolean): string {
|
||||
posRef.current = enabled ? 0 : target.length;
|
||||
cpsRef.current = 0;
|
||||
lastRef.current = 0;
|
||||
setShownLen(enabled ? 0 : target.length);
|
||||
committedRef.current = enabled ? 0 : target.length;
|
||||
nodeRef.current = null;
|
||||
setCommittedLen(enabled ? 0 : target.length);
|
||||
}
|
||||
}, [target.length, enabled]);
|
||||
|
||||
// ZERO added TTFT: on the very first frame content exists, reveal a few chars
|
||||
// in-render instead of waiting a frame for the first rAF tick.
|
||||
if (!enabled) return target;
|
||||
const effectiveShown = (shownLen === 0 && target.length > 0)
|
||||
? Math.min(3, target.length)
|
||||
: shownLen;
|
||||
return target.slice(0, effectiveShown);
|
||||
if (!enabled) return { text: target, revealRef };
|
||||
return { text: target.slice(0, committedLen), revealRef };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user