diff --git a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx index e8275c9c..b9cb6f03 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx +++ b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx @@ -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; +}> = ({ 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( isStreaming ? Date.now() : null ); - const [elapsed, setElapsed] = useState(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(null); const expanded = userOverride ?? !!isStreaming; @@ -774,7 +766,7 @@ const ThinkingBubble: React.FC<{ > {text ? ( <> - {text} + {text} {isStreaming && } ) : ( @@ -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; } -const MessageBubble: React.FC = React.memo(({ message, editing = false, onSaveEdit, onCancelEdit, isStreaming, dynamicTurnLabel }) => { +const MessageBubble: React.FC = 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 = React.memo(({ message, editing = false, o = 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. */} + {renderedMarkdown} {isStreaming && } )} diff --git a/frontend/src/app/pages/AgentChat/bubbles/StreamingBubble.tsx b/frontend/src/app/pages/AgentChat/bubbles/StreamingBubble.tsx index fe6659b1..5f5dd97c 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/StreamingBubble.tsx +++ b/frontend/src/app/pages/AgentChat/bubbles/StreamingBubble.tsx @@ -21,7 +21,7 @@ const StreamingBubble: React.FC = ({ 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 = ({ sessionId, activeBranchId, turnLabel } { + const [committedLen, setCommittedLen] = useState(enabled ? 0 : target.length); + const revealRef = useRef(null); -export function useSmoothText(target: string, enabled: boolean): string { - const [shownLen, setShownLen] = useState(enabled ? 0 : target.length); - const rafRef = useRef(null); const targetRef = useRef(target); targetRef.current = target; @@ -40,22 +45,53 @@ export function useSmoothText(target: string, enabled: boolean): string { const posRef = useRef(enabled ? 0 : target.length); // float reveal position const cpsRef = useRef(0); // current reveal speed const lastRef = useRef(0); // last frame timestamp - const shownRef = useRef(shownLen); - shownRef.current = shownLen; + const committedRef = useRef(committedLen); + const lastCommitAtRef = useRef(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(committedLen); + const nodeRef = useRef(null); + const baseRef = useRef(''); + + 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 }; }