From 7630b023bfe336338d45d771253852bbc28166ae Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 20 Jul 2026 15:51:13 +0800 Subject: [PATCH] feat(web-ui): smooth desktop streaming typewriter reveal Replace fixed-interval catch-up typewriter and single-latency event batching with adaptive rAF reveal and dual-latency text flushes so streamed chat text paces evenly without stair-step bursts or end-of-turn chrome remount flashes. --- ...-20-desktop-streaming-typewriter-design.md | 76 +++++ .../Markdown/AsyncPrismSyntaxHighlighter.tsx | 9 +- .../components/Markdown/Markdown.tsx | 37 ++- .../src/flow_chat/components/ChatInput.scss | 13 +- .../flow_chat/components/FlowTextBlock.tsx | 52 ++-- .../flow_chat/components/RichTextInput.scss | 8 + .../components/modern/ModelRoundItem.scss | 25 +- .../components/modern/ModelRoundItem.tsx | 35 ++- .../components/modern/VirtualMessageList.tsx | 24 +- .../modern/modelRoundItemClassName.test.ts | 23 ++ .../modern/modelRoundItemClassName.ts | 18 ++ .../hooks/TypewriterRevealGate.test.tsx | 121 ++++++++ .../flow_chat/hooks/TypewriterRevealGate.tsx | 85 ++++++ .../src/flow_chat/hooks/useTypewriter.test.ts | 80 ++++++ .../src/flow_chat/hooks/useTypewriter.ts | 260 +++++++++++++++--- .../flow_chat/services/EventBatcher.test.ts | 93 ++++++- .../src/flow_chat/services/EventBatcher.ts | 129 ++++++--- .../flow-chat-manager/EventHandlerModule.ts | 6 +- .../store/modernFlowChatStore.test.ts | 79 ++++-- .../flow_chat/store/modernFlowChatStore.ts | 13 +- .../tool-cards/ModelThinkingDisplay.tsx | 15 +- 21 files changed, 1036 insertions(+), 165 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-20-desktop-streaming-typewriter-design.md create mode 100644 src/web-ui/src/flow_chat/components/modern/modelRoundItemClassName.test.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/modelRoundItemClassName.ts create mode 100644 src/web-ui/src/flow_chat/hooks/TypewriterRevealGate.test.tsx create mode 100644 src/web-ui/src/flow_chat/hooks/TypewriterRevealGate.tsx create mode 100644 src/web-ui/src/flow_chat/hooks/useTypewriter.test.ts diff --git a/docs/superpowers/specs/2026-07-20-desktop-streaming-typewriter-design.md b/docs/superpowers/specs/2026-07-20-desktop-streaming-typewriter-design.md new file mode 100644 index 0000000000..fc6d5f429a --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-desktop-streaming-typewriter-design.md @@ -0,0 +1,76 @@ +# Desktop Streaming Typewriter Smoothness + +**Date:** 2026-07-20 +**Status:** Approved for implementation (user authorized direct design + implement) + +## Problem + +bitfun-desktop session streaming text feels stepped / bursty compared with a smooth typewriter feel (mobile-web cited as a reference, not a target to copy). + +## Root Cause + +1. `EventBatcher` flushes all events on a fixed **100ms** cadence, so text arrives in stair steps. +2. `useTypewriter` uses `setInterval(50ms)` and **forces catch-up within 800ms**, so large batches raise `chars/tick` sharply and produce visible jumps. +3. Full Markdown re-parse on each display update makes oversized ticks more expensive and more visible. + +## Design Goals + +- Smooth, even visual velocity (not “true realtime at all costs”). +- Avoid feast/famine catch-up bursts. +- Keep tool-event batching protective (do not spam UI with ParamsPartial/Progress). +- Stay inside `src/web-ui` streaming path; no Rust / mobile-web changes. + +## Solution + +### 1. Adaptive rAF typewriter (`useTypewriter`) + +Replace fixed interval + 800ms catch-up with: + +- `requestAnimationFrame` loop +- Fractional character accumulator (sub-frame smoothness) +- Soft acceleration from backlog depth +- Hard cap on characters revealed per paint +- Minimum paint interval (~32ms) so Markdown is not forced to 60Hz full re-parse +- When the model stream ends (`animate === false`) while characters remain, + keep revealing with a finish-speed boost — never snap the remainder +- History / never-animated mounts still start at full text + +Tunable constants (exported for tests): + +| Constant | Intent | +|---|---| +| Base ~90 chars/s | Comfortable live reveal | +| Soft accel from backlog | Catch up without dumping | +| Live max ~720 chars/s / 18 chars/paint | Fast but stepped while streaming | +| Finish max ~2400 chars/s / 64 chars/paint | Fastest stepped drain after model ends | +| Min paint ~16ms live / ~8ms finish | Balance smoothness vs Markdown cost | + +Footer / round chrome wait for typewriter reveal completion via +`TypewriterRevealGate`, so copy/export actions do not appear (and reflow) +while characters are still being revealed. + +`ModelRoundItem` must not replay CSS `fadeIn` when flipping +`--streaming` → `--complete` after typewriter drain (that reset opacity to 0 +and looked like a full chat refresh). Enter animation is opt-in via +`--enter` only for freshly mounted non-streaming rounds. + +### 2. Dual-latency `EventBatcher` + +- Text chunk keys: **maxLatencyMs = 32** (steadier inflow for the typewriter) +- Tool / default events: **maxLatencyMs = 100** (unchanged protection) +- If a more urgent event arrives while a flush is already scheduled, **reschedule** to the earlier deadline + +`handleTextChunk` passes the text latency when calling `eventBatcher.add`. + +### 3. Out of scope (follow-ups) + +- Incremental / frozen-block Markdown parsing +- VirtualMessageList observer frequency changes +- mobile-web typewriter changes + +## Verification + +- Unit tests for reveal-step math and EventBatcher dual-latency / reschedule +- `pnpm run type-check:web` +- Focused vitest for touched modules +- Manual: stream a long mixed CJK/Latin reply in desktop and confirm even pacing without stair-step bursts diff --git a/src/web-ui/src/component-library/components/Markdown/AsyncPrismSyntaxHighlighter.tsx b/src/web-ui/src/component-library/components/Markdown/AsyncPrismSyntaxHighlighter.tsx index 1bbe48e36f..02ecc74925 100644 --- a/src/web-ui/src/component-library/components/Markdown/AsyncPrismSyntaxHighlighter.tsx +++ b/src/web-ui/src/component-library/components/Markdown/AsyncPrismSyntaxHighlighter.tsx @@ -17,6 +17,12 @@ interface AsyncPrismSyntaxHighlighterProps { lineNumberStyle?: React.CSSProperties; fallback?: React.ComponentType; fallbackProps?: FlowCodeBlockFallbackProps; + /** + * Keep the lightweight fallback mounted even when Prism is already loaded. + * Used while chat text is still streaming so we do not remount the code-block + * tree (Fallback ↔ Prism) on every streaming flag flip. + */ + preferFallback?: boolean; traceContext?: MarkdownTraceContext; children: string; } @@ -59,6 +65,7 @@ export const AsyncPrismSyntaxHighlighter: React.FC { @@ -140,7 +147,7 @@ export const AsyncPrismSyntaxHighlighter: React.FC ) : null; - if (!Highlighter) { + if (!Highlighter || preferFallback) { if (Fallback && fallbackProps) { return ( <> diff --git a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx index 96b36aa638..7a210cf7b7 100644 --- a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx +++ b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx @@ -3,7 +3,7 @@ * Used to render Markdown-formatted text */ -import React, { useState, useMemo, useCallback, useEffect, useLayoutEffect, Component, type ReactNode } from 'react'; +import React, { useState, useMemo, useCallback, useEffect, useLayoutEffect, useRef, Component, type ReactNode } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; @@ -797,6 +797,11 @@ export const Markdown = React.memo(({ }) => { const { isLight } = useTheme(); const [currentWorkspacePath, setCurrentWorkspacePath] = useState(''); + // Keep streaming flag out of `components` memo deps so flipping streaming + // mode does not rebuild the entire ReactMarkdown component map (that remount + // looked like the chat pane refreshed when a turn finished). + const isStreamingRef = useRef(isStreaming); + isStreamingRef.current = isStreaming; const syntaxTheme = useMemo(() => buildMarkdownPrismStyle(isLight), [isLight]); @@ -1067,11 +1072,13 @@ export const Markdown = React.memo(({ ); } + const streaming = isStreamingRef.current; + if (language.toLowerCase().startsWith('mermaid')) { return ( ); } @@ -1097,23 +1104,12 @@ export const Markdown = React.memo(({
- {isStreaming ? ( - // While the text is still streaming, skip the heavy Prism - // tokenization on every tick (it re-highlights the entire - // code each frame, which is the main source of code-block - // jitter in the chat). Render a lightweight, line-numbered - //
 that matches Prism's `showLineNumbers` layout so the
-            // gutter width and line indentation stay visually stable
-            // across the eventual fallback -> Prism swap when streaming
-            // completes.
-            
-          ) : (
+            {/*
+              Always mount AsyncPrismSyntaxHighlighter. While streaming,
+              preferFallback keeps the lightweight line-numbered pre so we do
+              not remount Fallback ↔ Prism when the turn finishes (that remount
+              flashed the chat pane).
+            */}
             (({
                 userSelect: 'none',
                 minWidth: '3em'
               }}
+              preferFallback={streaming}
               fallback={CodeBlockFallback}
               fallbackProps={{
                 code,
@@ -1139,7 +1136,6 @@ export const Markdown = React.memo(({
             >
               {code}
             
-          )}
           
); @@ -1387,7 +1383,6 @@ export const Markdown = React.memo(({ basePath, remoteConnectionId, expandDetailsByDefault, - isStreaming, markdownContent, handleFileViewRequest, handleRevealInExplorer, diff --git a/src/web-ui/src/flow_chat/components/ChatInput.scss b/src/web-ui/src/flow_chat/components/ChatInput.scss index 70c7c76b9b..c6ff5faf8e 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.scss +++ b/src/web-ui/src/flow_chat/components/ChatInput.scss @@ -95,7 +95,9 @@ align-items: center; min-height: 44px; max-height: 44px; - padding: 0 6px 0 2px; + // 7px horizontal padding matches the (44 - 2*1 - 28) / 2 = 7px vertical + // gap around the 28px round buttons, keeping all four sides equal + padding: 0 7px; border-radius: 22px; overflow: visible; @@ -109,7 +111,10 @@ flex-shrink: 0; display: flex; align-items: center; - padding: 0 2px 0 4px; + padding: 0; + // Match the 7px box padding so the round Plus button keeps equal + // clearance on all four sides (top/bottom/left/right) + margin-right: 7px; animation: none; // Enlarge the Plus button to match the capsule height @@ -153,7 +158,7 @@ display: flex; align-items: center; gap: $size-gap-1; - padding: 0 4px 0 2px; + padding: 0; animation: none; // Enlarge send/stop button to match the capsule height @@ -389,7 +394,7 @@ border-radius: 22px; min-height: 44px; max-height: 44px; - padding: 0 6px 0 2px; + padding: 0 7px; background: rgba(var(--private-chat-input-capsule-bg-rgb), 0.22); border: 1px solid var(--color-overlay-white-08); backdrop-filter: blur(8px); diff --git a/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx b/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx index e92c5a429b..0204f33e59 100644 --- a/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx +++ b/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx @@ -1,8 +1,7 @@ /** * Streaming text block component. - * Applies a typewriter effect during streaming to smooth out - * the batched content updates from EventBatcher (~100ms). - * Supports a streaming cursor indicator. + * Applies an adaptive typewriter during streaming to smoothly drain + * batched EventBatcher text updates. Supports a streaming cursor indicator. */ import React, { useState, useEffect, useRef } from 'react'; @@ -13,6 +12,7 @@ import type { MarkdownTraceContext } from '@/component-library'; import type { FlowTextItem } from '../types/flow-chat'; import { useFlowChatContext } from './modern/FlowChatContext'; import { useTypewriter } from '../hooks/useTypewriter'; +import { useReportTypewriterReveal } from '../hooks/TypewriterRevealGate'; import { isStartupRenderTraceEnabled } from '@/shared/utils/startupTrace'; import './FlowTextBlock.scss'; @@ -87,9 +87,34 @@ export const FlowTextBlock = React.memo(({ const isStreaming = textItem.isStreaming && (textItem.status === 'streaming' || textItem.status === 'running'); - const displayContent = useTypewriter(content, isStreaming, { + const { displayText: displayContent, isRevealing } = useTypewriter(content, isStreaming, { replayOnMount: replayStreamingOnMount, }); + useReportTypewriterReveal(textItem.id, isRevealing); + // Keep streaming render mode until the typewriter finishes draining so the + // Markdown path does not flash when the model completes early. + const isVisuallyStreaming = isStreaming || isRevealing; + // Leave Markdown streaming mode one frame after visual settle so footer / + // list layout commits first; avoids a same-frame Prism upgrade flash. + const [markdownStreaming, setMarkdownStreaming] = useState(isVisuallyStreaming); + useEffect(() => { + if (isVisuallyStreaming) { + setMarkdownStreaming(true); + return; + } + let cancelled = false; + const frameId = requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (!cancelled) { + setMarkdownStreaming(false); + } + }); + }); + return () => { + cancelled = true; + cancelAnimationFrame(frameId); + }; + }, [isVisuallyStreaming]); // Heuristic: if content does not change for a while, streaming is done. const [isContentGrowing, setIsContentGrowing] = useState(isStreaming); @@ -124,9 +149,9 @@ export const FlowTextBlock = React.memo(({ return clearGrowthTimeout; }, [content, isStreaming]); - const isActivelyStreaming = textItem.isStreaming && - (textItem.status === 'streaming' || textItem.status === 'running') && - isContentGrowing; + // Keep streaming chrome while either the model is actively emitting or the + // typewriter is still revealing leftover characters. + const isActivelyStreaming = (isStreaming && isContentGrowing) || isRevealing; const markdownTraceContext = isStartupRenderTraceEnabled() ? traceContext : undefined; if (textItem.runtimeStatus) { @@ -146,7 +171,7 @@ export const FlowTextBlock = React.memo(({ data-testid={testId} data-flow-item-id={textItem.id} data-status={textItem.status} - data-streaming={isStreaming ? 'true' : 'false'} + data-streaming={isVisuallyStreaming ? 'true' : 'false'} {...testAttributes} > {textItem.isMarkdown ? ( @@ -154,14 +179,9 @@ export const FlowTextBlock = React.memo(({ content={displayContent} basePath={markdownBasePath} remoteConnectionId={markdownRemoteConnectionId} - // Pass the raw streaming flag (not the idle-gated - // `isActivelyStreaming`) so the code-block render path inside - // Markdown stays stable across bursty AI output. Otherwise - // `isContentGrowing` toggles every >500ms idle and forces the - // fallback
 / Prism highlighter to swap back and forth,
-          // which makes line numbers and the code body visibly shake
-          // until the stream finally completes.
-          isStreaming={isStreaming}
+          // Prefer deferred visual streaming so Prism upgrade does not share a
+          // frame with footer insertion / list scroll settlement.
+          isStreaming={markdownStreaming}
           onFileViewRequest={onFileViewRequest}
           onTabOpen={onTabOpen}
           onHttpLinkClick={onHttpLinkClick}
diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.scss b/src/web-ui/src/flow_chat/components/RichTextInput.scss
index 8a068df6ea..0ef3551ac0 100644
--- a/src/web-ui/src/flow_chat/components/RichTextInput.scss
+++ b/src/web-ui/src/flow_chat/components/RichTextInput.scss
@@ -45,6 +45,14 @@
     border: none !important;
     box-shadow: none !important;
   }
+
+  // The global focus ring (`.bitfun-app-layout *:focus-visible`) adds
+  // border-radius for its outline. This input suppresses the ring, and the
+  // leftover radius clips the caret where overflow is hidden (capsule mode).
+  // Doubled class beats that rule's specificity regardless of load order.
+  &.rich-text-input:focus-visible {
+    border-radius: 0;
+  }
   
   &[contenteditable="false"] {
     opacity: 0.6;
diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss
index 7096dc0dbb..5b9af480c7 100644
--- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss
+++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss
@@ -5,11 +5,26 @@
    * No bottom margin — the inter-round gap is covered by the same line-height rhythm
    * as inter-item gaps inside a round, so flow text → tool / tool → thinking / cross-
    * round transitions all read as one continuous, evenly-spaced text column.
+   *
+   * Enter animation must NOT live on the base selector alone: rounds toggle
+   * `--streaming` → `--complete` when the typewriter finishes, and re-applying
+   * `animation: fadeIn` would replay opacity 0→1 (looks like the whole chat
+   * refreshed). Play fadeIn only for freshly mounted complete rounds via
+   * `--enter`; streaming/complete modifiers keep animation off.
    */
   margin-bottom: 0;
   padding: 0 var(--flowchat-content-inline-pad);
-  animation: fadeIn 0.4s ease;
   position: relative;
+  opacity: 1;
+
+  /*
+   * Freshly mounted history/complete rounds may opt into `--enter`.
+   * Rounds that began as `--streaming` omit `--enter`, so flipping to
+   * `--complete` later cannot replay fadeIn.
+   */
+  &--enter {
+    animation: fadeIn 0.4s ease;
+  }
 
   // Streaming state
   &--streaming {
@@ -24,7 +39,7 @@
     }
   }
 
-  // Complete state
+  // Complete state — keep opacity stable; do not attach an animation here.
   &--complete {
     opacity: 1;
   }
@@ -50,6 +65,12 @@
   gap: var(--flowchat-inline-gap);
   padding: var(--flowchat-inline-gap) 0;
   margin-top: var(--flowchat-inline-gap);
+
+  // Reserve layout while typewriter catch-up finishes; reveal without resize.
+  &--pending {
+    visibility: hidden;
+    pointer-events: none;
+  }
 }
 
 .model-round-item__meta {
diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx
index 3d85cadd40..3c9d43828b 100644
--- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx
+++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx
@@ -15,6 +15,11 @@ import { useI18n } from '@/infrastructure/i18n';
 import { FlowTextBlock } from '../FlowTextBlock';
 import { FlowToolCard } from '../FlowToolCard';
 import { ModelThinkingDisplay } from '../../tool-cards/ModelThinkingDisplay';
+import {
+  TypewriterRevealGateProvider,
+  useCreateTypewriterRevealGate,
+} from '../../hooks/TypewriterRevealGate';
+import { getModelRoundItemClassName } from './modelRoundItemClassName';
 import { isCollapsibleTool } from '../../tool-cards/toolCardMetadata';
 import { useFlowChatContext } from './FlowChatContext';
 import { FlowChatStore } from '../../store/FlowChatStore';
@@ -245,6 +250,11 @@ export const ModelRoundItem = React.memo(
     const { t } = useTranslation('flow-chat');
     const { formatDate, formatNumber } = useI18n('flow-chat');
     const { sessionId } = useFlowChatContext();
+    const typewriterRevealGate = useCreateTypewriterRevealGate();
+    // Capture mount-time streaming state once: history rounds may fade in,
+    // but a round that started as streaming must never replay fadeIn when it
+    // later flips to complete (that looked like a full chat refresh).
+    const [shouldPlayEnterAnimation] = useState(() => !round.isStreaming);
     const [copied, setCopied] = useState(false);
     const [showRetryHistory, setShowRetryHistory] = useState(false);
     const [showRoundHistory, setShowRoundHistory] = useState(false);
@@ -594,21 +604,30 @@ export const ModelRoundItem = React.memo(
       formatNumber,
       t,
     }), [completedAt, effectiveDurationMs, formatDate, formatNumber, round.status, t, turnTokenUsage]);
-    const shouldRenderFooter = isTurnComplete &&
+    // Wait for typewriter catch-up before revealing footer controls. Reserve
+    // footer layout as soon as the model round completes so the eventual
+    // reveal does not resize the list (that resize flashed the chat pane).
+    const isVisuallyStreaming = round.isStreaming || typewriterRevealGate.isAnyRevealing;
+    const shouldReserveFooter = isTurnComplete &&
       isLastRound &&
       !round.isStreaming &&
       (hasContent || usageMetaItems.length > 0);
+    const shouldRevealFooter = shouldReserveFooter && !typewriterRevealGate.isAnyRevealing;
     
     return (
+      
       
{renderTraceEnabled && renderTraceStartedAtMs !== null && allGroupSummary && visibleGroupSummary && ( (
)} - {shouldRenderFooter && ( -
+ {shouldReserveFooter && ( +
{usageMetaItems.length > 0 && (
( ref={copyButtonRef} className={`model-round-item__action-btn model-round-item__copy-btn ${copied ? 'copied' : ''}`} onClick={handleCopy} + tabIndex={shouldRevealFooter ? 0 : -1} + disabled={!shouldRevealFooter} > {copied ? : } @@ -792,6 +816,7 @@ export const ModelRoundItem = React.memo(
)}
+ ); }, (prev, next) => { diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx index 54e7945e07..f573ea5ade 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -3069,6 +3069,12 @@ const VirtualMessageListSession = forwardRef { - const scroller = scrollerElementRef.current; - if (!scroller) { + const liveScroller = scrollerElementRef.current; + if (!liveScroller) { return; } - scroller.scrollTo({ - top: Math.max(0, scroller.scrollHeight - scroller.clientHeight), - behavior: 'auto', - }); + const maxScrollTop = Math.max(0, liveScroller.scrollHeight - liveScroller.clientHeight); + // Avoid a no-op scrollTo that still forces a layout pass / visual hitch. + if (Math.abs(liveScroller.scrollTop - maxScrollTop) > 1) { + liveScroller.scrollTop = maxScrollTop; + } staticInitialHistoryUserLeftBottomRef.current = false; }); }, [applyFooterCompensationNow, clearPinReservationForUserNavigation, isStreamingOutput, updateBottomReservationState]); diff --git a/src/web-ui/src/flow_chat/components/modern/modelRoundItemClassName.test.ts b/src/web-ui/src/flow_chat/components/modern/modelRoundItemClassName.test.ts new file mode 100644 index 0000000000..413a844adc --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/modelRoundItemClassName.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { getModelRoundItemClassName } from './modelRoundItemClassName'; + +describe('getModelRoundItemClassName', () => { + it('does not attach enter animation when a streaming round becomes complete', () => { + expect(getModelRoundItemClassName({ + isVisuallyStreaming: true, + shouldPlayEnterAnimation: false, + })).toBe('model-round-item model-round-item--streaming'); + + expect(getModelRoundItemClassName({ + isVisuallyStreaming: false, + shouldPlayEnterAnimation: false, + })).toBe('model-round-item model-round-item--complete'); + }); + + it('allows enter animation only for freshly mounted complete rounds', () => { + expect(getModelRoundItemClassName({ + isVisuallyStreaming: false, + shouldPlayEnterAnimation: true, + })).toBe('model-round-item model-round-item--complete model-round-item--enter'); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/modelRoundItemClassName.ts b/src/web-ui/src/flow_chat/components/modern/modelRoundItemClassName.ts new file mode 100644 index 0000000000..7bfa5e0c87 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/modelRoundItemClassName.ts @@ -0,0 +1,18 @@ +/** + * Build ModelRoundItem root class names. + * + * Important: rounds that begin streaming must not later pick up an enter + * animation when they flip to complete — that replays opacity 0→1 and looks + * like the whole conversation refreshed. + */ +export function getModelRoundItemClassName(params: { + isVisuallyStreaming: boolean; + shouldPlayEnterAnimation: boolean; +}): string { + const { isVisuallyStreaming, shouldPlayEnterAnimation } = params; + return [ + 'model-round-item', + isVisuallyStreaming ? 'model-round-item--streaming' : 'model-round-item--complete', + shouldPlayEnterAnimation ? 'model-round-item--enter' : '', + ].filter(Boolean).join(' '); +} diff --git a/src/web-ui/src/flow_chat/hooks/TypewriterRevealGate.test.tsx b/src/web-ui/src/flow_chat/hooks/TypewriterRevealGate.test.tsx new file mode 100644 index 0000000000..0c6f637b11 --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/TypewriterRevealGate.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment jsdom +/** + * Regression tests for TypewriterRevealGate. + * + * The reporter effect must depend on the stable `report` function only. + * Depending on the whole gate object (new identity per report) used to cause + * an infinite report → re-render → cleanup → report loop that froze the app + * whenever a streaming typewriter was revealing. + */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + TypewriterRevealGateProvider, + useCreateTypewriterRevealGate, + useReportTypewriterReveal, + useTypewriterRevealGate, +} from './TypewriterRevealGate'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root | null = null; + +afterEach(() => { + if (root) { + act(() => root!.unmount()); + root = null; + } + container?.remove(); +}); + +function Consumer({ id, revealing }: { id: string; revealing: boolean }) { + useReportTypewriterReveal(id, revealing); + return null; +} + +function Observer({ onState }: { onState: (v: boolean) => void }) { + const gate = useTypewriterRevealGate(); + onState(Boolean(gate?.isAnyRevealing)); + return null; +} + +function Probe({ + consumers, + onState, +}: { + consumers: Array<{ id: string; revealing: boolean }>; + onState: (v: boolean) => void; +}) { + const gate = useCreateTypewriterRevealGate(); + return ( + + {consumers.map((c) => ( + + ))} + + + ); +} + +function mount(consumers: Array<{ id: string; revealing: boolean }>, states: boolean[]) { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root!.render( states.push(v)} />); + }); +} + +describe('TypewriterRevealGate', () => { + it('settles when a consumer reports revealing=true', () => { + const states: boolean[] = []; + mount([{ id: 'text-1', revealing: true }], states); + + // Should settle quickly, not re-render forever. + expect(states.length).toBeLessThan(10); + expect(states[states.length - 1]).toBe(true); + }); + + it('clears the gate when the last consumer stops revealing', () => { + const states: boolean[] = []; + mount([{ id: 'text-1', revealing: true }], states); + expect(states[states.length - 1]).toBe(true); + + act(() => { + root!.render( + states.push(v)} /> + ); + }); + + expect(states.length).toBeLessThan(20); + expect(states[states.length - 1]).toBe(false); + }); + + it('stays gated while any consumer is still revealing', () => { + const states: boolean[] = []; + mount( + [ + { id: 'text-1', revealing: true }, + { id: 'thinking-1', revealing: true }, + ], + states + ); + expect(states[states.length - 1]).toBe(true); + + act(() => { + root!.render( + states.push(v)} + /> + ); + }); + + expect(states[states.length - 1]).toBe(true); + }); +}); diff --git a/src/web-ui/src/flow_chat/hooks/TypewriterRevealGate.tsx b/src/web-ui/src/flow_chat/hooks/TypewriterRevealGate.tsx new file mode 100644 index 0000000000..8d5591582b --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/TypewriterRevealGate.tsx @@ -0,0 +1,85 @@ +/** + * Lets nested typewriter consumers report whether they are still revealing, + * so parents (e.g. ModelRoundItem footer) can wait for visual completion. + */ + +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; + +interface TypewriterRevealGateValue { + report: (key: string, revealing: boolean) => void; + isAnyRevealing: boolean; +} + +const TypewriterRevealGateContext = createContext(null); + +export function useCreateTypewriterRevealGate(): TypewriterRevealGateValue { + const [revealingKeys, setRevealingKeys] = useState>(() => new Set()); + + const report = useCallback((key: string, revealing: boolean) => { + setRevealingKeys((previous) => { + const hasKey = previous.has(key); + if (revealing === hasKey) { + return previous; + } + const next = new Set(previous); + if (revealing) { + next.add(key); + } else { + next.delete(key); + } + return next; + }); + }, []); + + return useMemo(() => ({ + report, + isAnyRevealing: revealingKeys.size > 0, + }), [report, revealingKeys]); +} + +export const TypewriterRevealGateProvider: React.FC<{ + value?: TypewriterRevealGateValue; + children: ReactNode; +}> = ({ value, children }) => { + const localValue = useCreateTypewriterRevealGate(); + return ( + + {children} + + ); +}; + +export function useTypewriterRevealGate(): TypewriterRevealGateValue | null { + return useContext(TypewriterRevealGateContext); +} + +/** + * Report a typewriter reveal key for the lifetime of `isRevealing === true`. + * + * Depends on the stable `report` function only — the gate value object gets a + * new identity on every reported change, so depending on it would re-run the + * effect on each report: cleanup removes the key, the body re-adds it, and + * the pair of state updates changes the gate identity again (infinite loop). + */ +export function useReportTypewriterReveal(key: string, isRevealing: boolean): void { + const gate = useTypewriterRevealGate(); + const report = gate?.report; + + useEffect(() => { + if (!report) { + return; + } + report(key, isRevealing); + return () => { + report(key, false); + }; + }, [report, isRevealing, key]); +} diff --git a/src/web-ui/src/flow_chat/hooks/useTypewriter.test.ts b/src/web-ui/src/flow_chat/hooks/useTypewriter.test.ts new file mode 100644 index 0000000000..e0639b561c --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/useTypewriter.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { + TYPEWRITER_BASE_CHARS_PER_SEC, + TYPEWRITER_FINISH_CHARS_PER_SEC, + TYPEWRITER_FINISH_MAX_CHARS_PER_PAINT, + TYPEWRITER_MAX_CHARS_PER_PAINT, + TYPEWRITER_MAX_CHARS_PER_SEC, + commitTypewriterReveal, + computeTypewriterCharsPerSec, +} from './useTypewriter'; + +describe('computeTypewriterCharsPerSec', () => { + it('starts near the base rate with an empty backlog', () => { + expect(computeTypewriterCharsPerSec(0)).toBe(TYPEWRITER_BASE_CHARS_PER_SEC); + }); + + it('soft-accelerates as backlog grows', () => { + const small = computeTypewriterCharsPerSec(10); + const large = computeTypewriterCharsPerSec(100); + expect(small).toBeGreaterThan(TYPEWRITER_BASE_CHARS_PER_SEC); + expect(large).toBeGreaterThan(small); + }); + + it('never exceeds the live ceiling while streaming', () => { + expect(computeTypewriterCharsPerSec(10_000)).toBe(TYPEWRITER_MAX_CHARS_PER_SEC); + }); + + it('uses the absolute finish rate once the model stream ends', () => { + expect(computeTypewriterCharsPerSec({ backlog: 20, finishing: true })) + .toBe(TYPEWRITER_FINISH_CHARS_PER_SEC); + expect(computeTypewriterCharsPerSec({ backlog: 1, finishing: true })) + .toBe(TYPEWRITER_FINISH_CHARS_PER_SEC); + expect(TYPEWRITER_FINISH_CHARS_PER_SEC).toBeGreaterThan(TYPEWRITER_MAX_CHARS_PER_SEC); + }); +}); + +describe('commitTypewriterReveal', () => { + it('does not reveal more than the per-paint cap', () => { + const result = commitTypewriterReveal({ + backlog: 200, + fractionalCarry: 50, + }); + expect(result.chars).toBe(TYPEWRITER_MAX_CHARS_PER_PAINT); + expect(result.fractionalCarry).toBe(50 - TYPEWRITER_MAX_CHARS_PER_PAINT); + }); + + it('honors a higher finish-mode paint cap', () => { + const result = commitTypewriterReveal({ + backlog: 200, + fractionalCarry: 80, + maxCharsPerPaint: TYPEWRITER_FINISH_MAX_CHARS_PER_PAINT, + }); + expect(result.chars).toBe(TYPEWRITER_FINISH_MAX_CHARS_PER_PAINT); + }); + + it('does not reveal more than the backlog', () => { + const result = commitTypewriterReveal({ + backlog: 2, + fractionalCarry: 10, + }); + expect(result.chars).toBe(2); + expect(result.fractionalCarry).toBe(8); + }); + + it('keeps fractional remainder below one character', () => { + const result = commitTypewriterReveal({ + backlog: 20, + fractionalCarry: 2.75, + }); + expect(result.chars).toBe(2); + expect(result.fractionalCarry).toBeCloseTo(0.75); + }); + + it('returns zero when there is nothing to reveal', () => { + expect(commitTypewriterReveal({ backlog: 0, fractionalCarry: 4 })).toEqual({ + chars: 0, + fractionalCarry: 0, + }); + }); +}); diff --git a/src/web-ui/src/flow_chat/hooks/useTypewriter.ts b/src/web-ui/src/flow_chat/hooks/useTypewriter.ts index ddb8e1536c..43467dcb08 100644 --- a/src/web-ui/src/flow_chat/hooks/useTypewriter.ts +++ b/src/web-ui/src/flow_chat/hooks/useTypewriter.ts @@ -1,19 +1,50 @@ /** - * Typewriter hook for smoothing batched streaming updates. + * Adaptive typewriter for streaming text. * - * The EventBatcher flushes content every ~100ms, which makes text appear - * in jarring chunks. This hook interpolates between batched updates to - * produce a smooth character-by-character reveal. + * Incoming content is often batched (EventBatcher). This hook drains the + * backlog with rAF-aligned ticks, soft acceleration from backlog depth, and + * a hard per-paint character cap so reveals stay stepped rather than snapped. * - * When `animate` is false the full text is returned immediately — - * suitable for completed / history items. + * When the model stream ends (`animate` becomes false) the hook keeps + * revealing at the maximum finish rate until the backlog is drained — it + * never snaps the remaining text into view. History / non-animated mounts + * still start at the full target text. */ import { useState, useEffect, useRef } from 'react'; -const FRAME_INTERVAL = 50; // ms per tick – aligned with MutationObserver throttle -const REVEAL_DURATION = 800; // ms to reveal a new batch -const MIN_CHARS_PER_TICK = 3; +/** Steady reveal rate while backlog is small (live streaming). */ +export const TYPEWRITER_BASE_CHARS_PER_SEC = 90; + +/** + * Extra chars/sec added per backlog character during live streaming. + * Example: backlog 100 → +200 chars/sec on top of base (before cap). + */ +export const TYPEWRITER_ACCEL_CHARS_PER_SEC_PER_BACKLOG = 2; + +/** Live-stream ceiling before finish mode. */ +export const TYPEWRITER_MAX_CHARS_PER_SEC = 720; + +/** + * Absolute ceiling used after the model stream ends. Drain remaining text + * as fast as stepped paints allow. + */ +export const TYPEWRITER_FINISH_CHARS_PER_SEC = 2400; + +/** Live streaming hard cap per React paint. */ +export const TYPEWRITER_MAX_CHARS_PER_PAINT = 18; + +/** Finish-mode hard cap per React paint — still stepped, much faster. */ +export const TYPEWRITER_FINISH_MAX_CHARS_PER_PAINT = 64; + +/** + * Minimum time between setState paints during live streaming. Keeps Markdown + * from being forced to a full re-parse on every display frame. + */ +export const TYPEWRITER_MIN_PAINT_INTERVAL_MS = 16; + +/** Finish mode paints every animation frame budget. */ +export const TYPEWRITER_FINISH_MIN_PAINT_INTERVAL_MS = 8; export interface TypewriterOptions { /** @@ -24,69 +55,212 @@ export interface TypewriterOptions { replayOnMount?: boolean; } +export interface TypewriterCharsPerSecInput { + backlog: number; + /** True when the model stream has ended but characters remain to reveal. */ + finishing?: boolean; +} + +export interface TypewriterResult { + displayText: string; + /** True while displayed text is still catching up to the target. */ + isRevealing: boolean; +} + +/** + * Soft-accelerated reveal rate for the current backlog. + * Finish mode jumps to the absolute maximum drain rate. + */ +export function computeTypewriterCharsPerSec( + backlogOrInput: number | TypewriterCharsPerSecInput, + finishingArg?: boolean +): number { + const backlog = typeof backlogOrInput === 'number' + ? backlogOrInput + : backlogOrInput.backlog; + const finishing = typeof backlogOrInput === 'number' + ? Boolean(finishingArg) + : Boolean(backlogOrInput.finishing); + + if (finishing) { + return TYPEWRITER_FINISH_CHARS_PER_SEC; + } + + const safeBacklog = Math.max(0, backlog); + return Math.min( + TYPEWRITER_BASE_CHARS_PER_SEC + + safeBacklog * TYPEWRITER_ACCEL_CHARS_PER_SEC_PER_BACKLOG, + TYPEWRITER_MAX_CHARS_PER_SEC + ); +} + +export interface TypewriterRevealCommitInput { + backlog: number; + fractionalCarry: number; + maxCharsPerPaint?: number; +} + +export interface TypewriterRevealCommit { + chars: number; + fractionalCarry: number; +} + +/** + * Commit accumulated fractional characters into an integer paint step. + */ +export function commitTypewriterReveal( + input: TypewriterRevealCommitInput +): TypewriterRevealCommit { + const backlog = Math.max(0, Math.floor(input.backlog)); + const maxCharsPerPaint = Math.max( + 1, + input.maxCharsPerPaint ?? TYPEWRITER_MAX_CHARS_PER_PAINT + ); + let fractionalCarry = Number.isFinite(input.fractionalCarry) + ? Math.max(0, input.fractionalCarry) + : 0; + + if (backlog <= 0) { + return { chars: 0, fractionalCarry: 0 }; + } + + const chars = Math.min( + backlog, + Math.floor(fractionalCarry), + maxCharsPerPaint + ); + fractionalCarry = Math.max(0, fractionalCarry - chars); + return { chars, fractionalCarry }; +} + export function useTypewriter( targetText: string, animate: boolean, options: TypewriterOptions = {} -): string { +): TypewriterResult { const replayOnMount = options.replayOnMount ?? true; const shouldReplayInitialText = animate && replayOnMount; const [displayText, setDisplayText] = useState(shouldReplayInitialText ? '' : targetText); const revealedRef = useRef(shouldReplayInitialText ? 0 : targetText.length); const targetRef = useRef(targetText); - const timerRef = useRef | null>(null); - const speedRef = useRef(MIN_CHARS_PER_TICK); + const animateRef = useRef(animate); + const rafRef = useRef(null); + const lastTickMsRef = useRef(null); + const lastPaintMsRef = useRef(0); + const fractionalCarryRef = useRef(0); + + const isRevealing = animate || displayText.length < targetText.length; useEffect(() => { - if (!animate) { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; + animateRef.current = animate; + targetRef.current = targetText; + + // Reset when target shrinks (e.g. new round). + if (targetText.length < revealedRef.current) { + revealedRef.current = 0; + fractionalCarryRef.current = 0; + lastPaintMsRef.current = 0; + setDisplayText(''); + } + + // History / completed mounts that never started a reveal stay fully shown. + // If we are still behind when `animate` flips false, fall through and keep + // draining with the typewriter instead of snapping the remainder. + if (!animate && revealedRef.current >= targetText.length) { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; } + lastTickMsRef.current = null; + fractionalCarryRef.current = 0; revealedRef.current = targetText.length; - targetRef.current = targetText; setDisplayText(targetText); return; } - targetRef.current = targetText; + const tick = (nowMs: number) => { + rafRef.current = null; - // Reset when target shrinks (e.g. new round). - if (targetText.length < revealedRef.current) { - revealedRef.current = 0; - } + const target = targetRef.current; + let revealed = revealedRef.current; + + if (target.length < revealed) { + revealed = 0; + revealedRef.current = 0; + fractionalCarryRef.current = 0; + } + + const backlog = target.length - revealed; + if (backlog <= 0) { + lastTickMsRef.current = null; + fractionalCarryRef.current = 0; + if (revealedRef.current !== target.length) { + revealedRef.current = target.length; + setDisplayText(target); + } + return; + } + + const lastTickMs = lastTickMsRef.current; + lastTickMsRef.current = nowMs; + const finishing = !animateRef.current; + const minPaintInterval = finishing + ? TYPEWRITER_FINISH_MIN_PAINT_INTERVAL_MS + : TYPEWRITER_MIN_PAINT_INTERVAL_MS; + const maxCharsPerPaint = finishing + ? TYPEWRITER_FINISH_MAX_CHARS_PER_PAINT + : TYPEWRITER_MAX_CHARS_PER_PAINT; + const dtMs = lastTickMs === null + ? minPaintInterval + : Math.min(100, Math.max(0, nowMs - lastTickMs)); - const delta = targetText.length - revealedRef.current; - if (delta > 0) { - const totalFrames = REVEAL_DURATION / FRAME_INTERVAL; - speedRef.current = Math.max(Math.ceil(delta / totalFrames), MIN_CHARS_PER_TICK); - - if (!timerRef.current) { - timerRef.current = setInterval(() => { - const target = targetRef.current; - const cur = revealedRef.current; - if (cur >= target.length) { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - return; - } - const next = Math.min(cur + speedRef.current, target.length); + const charsPerSec = computeTypewriterCharsPerSec({ backlog, finishing }); + fractionalCarryRef.current += (charsPerSec * dtMs) / 1000; + fractionalCarryRef.current = Math.min( + fractionalCarryRef.current, + backlog + maxCharsPerPaint + ); + + const sincePaint = nowMs - lastPaintMsRef.current; + const canPaint = lastPaintMsRef.current === 0 + || sincePaint >= minPaintInterval; + + if (canPaint) { + const committed = commitTypewriterReveal({ + backlog, + fractionalCarry: fractionalCarryRef.current, + maxCharsPerPaint, + }); + fractionalCarryRef.current = committed.fractionalCarry; + + if (committed.chars > 0) { + const next = revealed + committed.chars; revealedRef.current = next; + lastPaintMsRef.current = nowMs; setDisplayText(target.slice(0, next)); - }, FRAME_INTERVAL); + } } + + if (revealedRef.current < targetRef.current.length) { + rafRef.current = requestAnimationFrame(tick); + } else { + lastTickMsRef.current = null; + } + }; + + if (rafRef.current === null && targetText.length > revealedRef.current) { + rafRef.current = requestAnimationFrame(tick); } }, [targetText, animate]); useEffect(() => { return () => { - if (timerRef.current) { - clearInterval(timerRef.current); + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; } }; }, []); - return displayText; + return { displayText, isRevealing }; } diff --git a/src/web-ui/src/flow_chat/services/EventBatcher.test.ts b/src/web-ui/src/flow_chat/services/EventBatcher.test.ts index 6617a25b50..cabce592dd 100644 --- a/src/web-ui/src/flow_chat/services/EventBatcher.test.ts +++ b/src/web-ui/src/flow_chat/services/EventBatcher.test.ts @@ -1,6 +1,9 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { setIncludeSensitiveDiagnostics } from '@/shared/utils/logger'; import { + DEFAULT_EVENT_MAX_LATENCY_MS, + EventBatcher, + TEXT_CHUNK_MAX_LATENCY_MS, generateTextChunkKey, generateToolEventKey, getBatchedEventsLogPayload, @@ -28,6 +31,7 @@ describe('summarizeBatchedEventsForLog', () => { strategy: 'accumulate', sourceCount: 12, timestamp: 1000, + maxLatencyMs: 100, }, ]; @@ -51,6 +55,7 @@ describe('summarizeBatchedEventsForLog', () => { strategy: 'accumulate', sourceCount: 12, timestamp: 1000, + maxLatencyMs: 100, }, ]; @@ -113,3 +118,89 @@ describe('generateToolEventKey', () => { })); }); }); + +describe('EventBatcher dual latency', () => { + const rafCallbacks = new Map(); + let nextRafId = 1; + + async function drainAnimationFrames(): Promise { + const pending = [...rafCallbacks.entries()]; + rafCallbacks.clear(); + for (const [, cb] of pending) { + cb(performance.now()); + } + } + + beforeEach(() => { + nextRafId = 1; + rafCallbacks.clear(); + vi.useFakeTimers({ now: 0 }); + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + const id = nextRafId++; + rafCallbacks.set(id, cb); + return id; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + rafCallbacks.delete(id); + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('flushes text events near the text latency budget', async () => { + const onFlush = vi.fn(); + const batcher = new EventBatcher({ onFlush }); + + batcher.add('text:s:r:text:none', { text: 'a' }, 'accumulate', (a, b) => ({ + text: `${a.text}${b.text}`, + }), { maxLatencyMs: TEXT_CHUNK_MAX_LATENCY_MS }); + + await vi.advanceTimersByTimeAsync(TEXT_CHUNK_MAX_LATENCY_MS - 1); + await drainAnimationFrames(); + expect(onFlush).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + await drainAnimationFrames(); + expect(onFlush).toHaveBeenCalledTimes(1); + expect(onFlush.mock.calls[0][0][0].payload.text).toBe('a'); + batcher.destroy(); + }); + + it('keeps default tool latency at 100ms', async () => { + const onFlush = vi.fn(); + const batcher = new EventBatcher({ onFlush }); + + batcher.add('tool:progress:s:t:none', { progress: 1 }, 'replace'); + + await vi.advanceTimersByTimeAsync(DEFAULT_EVENT_MAX_LATENCY_MS - 1); + await drainAnimationFrames(); + expect(onFlush).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + await drainAnimationFrames(); + expect(onFlush).toHaveBeenCalledTimes(1); + batcher.destroy(); + }); + + it('reschedules earlier when a text event arrives after a tool event', async () => { + const onFlush = vi.fn(); + const batcher = new EventBatcher({ onFlush }); + + batcher.add('tool:progress:s:t:none', { progress: 1 }, 'replace'); + await vi.advanceTimersByTimeAsync(10); + + batcher.add('text:s:r:text:none', { text: 'hi' }, 'replace', undefined, { + maxLatencyMs: TEXT_CHUNK_MAX_LATENCY_MS, + }); + + // Original tool schedule would still be ~90ms away; text should pull flush forward. + await vi.advanceTimersByTimeAsync(TEXT_CHUNK_MAX_LATENCY_MS); + await drainAnimationFrames(); + expect(onFlush).toHaveBeenCalledTimes(1); + expect(onFlush.mock.calls[0][0]).toHaveLength(2); + batcher.destroy(); + }); +}); diff --git a/src/web-ui/src/flow_chat/services/EventBatcher.ts b/src/web-ui/src/flow_chat/services/EventBatcher.ts index 66b2d0c27a..70d2a24332 100644 --- a/src/web-ui/src/flow_chat/services/EventBatcher.ts +++ b/src/web-ui/src/flow_chat/services/EventBatcher.ts @@ -16,6 +16,15 @@ const log = createLogger('EventBatcher'); export type MergeStrategy = 'accumulate' | 'replace'; +/** Default max latency for tool / non-text batched events. */ +export const DEFAULT_EVENT_MAX_LATENCY_MS = 100; + +/** + * Max latency for streaming text chunks. Keeps inflow steady for the + * adaptive typewriter without forcing a flush on every token. + */ +export const TEXT_CHUNK_MAX_LATENCY_MS = 32; + export interface BatchedEvent { key: string; payload: T; @@ -23,6 +32,12 @@ export interface BatchedEvent { accumulator?: (existing: T, incoming: T) => T; sourceCount: number; timestamp: number; + /** Flush this event (and the batch) within this many ms. */ + maxLatencyMs: number; +} + +export interface EventBatcherAddOptions { + maxLatencyMs?: number; } export interface EventBatcherOptions { @@ -83,12 +98,13 @@ export function getBatchedEventsLogPayload( return { rawEventCount, mergedEventCount, - mergedEvents: bufferedEvents.map(({ key, payload, strategy, sourceCount, timestamp }) => ({ + mergedEvents: bufferedEvents.map(({ key, payload, strategy, sourceCount, timestamp, maxLatencyMs }) => ({ key, payload, strategy, sourceCount, timestamp, + maxLatencyMs, })), }; } @@ -99,10 +115,10 @@ export class EventBatcher { private onFlush: (events: Array<{ key: string; payload: any }>) => void; private frameId: number | null = null; - // Update frequency control to prevent UI blocking with many events - private UPDATE_INTERVAL = 100; // Update every 100ms instead of every frame (16.67ms) private lastUpdateTime = 0; private timeoutId: ReturnType | null = null; + /** Delay currently armed for the pending flush; used to reschedule earlier. */ + private scheduledDelayMs: number | null = null; constructor(options: EventBatcherOptions) { this.onFlush = options.onFlush; @@ -112,8 +128,13 @@ export class EventBatcher { key: string, payload: T, strategy: MergeStrategy = 'replace', - accumulator?: (existing: T, incoming: T) => T + accumulator?: (existing: T, incoming: T) => T, + options?: EventBatcherAddOptions ): void { + const maxLatencyMs = Math.max( + 0, + options?.maxLatencyMs ?? DEFAULT_EVENT_MAX_LATENCY_MS + ); const existing = this.buffer.get(key); if (existing) { @@ -125,6 +146,7 @@ export class EventBatcher { existing.timestamp = Date.now(); } existing.sourceCount += 1; + existing.maxLatencyMs = Math.min(existing.maxLatencyMs, maxLatencyMs); } else { this.buffer.set(key, { key, @@ -132,39 +154,78 @@ export class EventBatcher { strategy, accumulator, sourceCount: 1, - timestamp: Date.now() + timestamp: Date.now(), + maxLatencyMs, }); } this.scheduleFlush(); } - private scheduleFlush(): void { - if (this.scheduled) return; + private getBufferMaxLatencyMs(): number { + let minLatency = DEFAULT_EVENT_MAX_LATENCY_MS; + for (const event of this.buffer.values()) { + minLatency = Math.min(minLatency, event.maxLatencyMs); + } + return minLatency; + } + + private cancelScheduledFlush(): void { + if (this.frameId !== null) { + cancelAnimationFrame(this.frameId); + this.frameId = null; + } + if (this.timeoutId !== null) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + this.scheduled = false; + this.scheduledDelayMs = null; + } + + private armFlush(delayMs: number): void { this.scheduled = true; + this.scheduledDelayMs = delayMs; - const now = nowMs(); - const timeSinceLastUpdate = now - this.lastUpdateTime; + if (delayMs <= 0) { + this.frameId = requestAnimationFrame(() => { + this.flush(); + this.scheduled = false; + this.frameId = null; + this.scheduledDelayMs = null; + this.lastUpdateTime = nowMs(); + }); + return; + } - if (timeSinceLastUpdate >= this.UPDATE_INTERVAL) { + this.timeoutId = setTimeout(() => { + this.timeoutId = null; this.frameId = requestAnimationFrame(() => { this.flush(); this.scheduled = false; this.frameId = null; + this.scheduledDelayMs = null; this.lastUpdateTime = nowMs(); }); - } else { - const delay = this.UPDATE_INTERVAL - timeSinceLastUpdate; - this.timeoutId = setTimeout(() => { - this.frameId = requestAnimationFrame(() => { - this.flush(); - this.scheduled = false; - this.frameId = null; - this.lastUpdateTime = nowMs(); - }); - this.timeoutId = null; - }, delay); + }, delayMs); + } + + private scheduleFlush(): void { + const now = nowMs(); + const maxLatencyMs = this.getBufferMaxLatencyMs(); + const timeSinceLastUpdate = now - this.lastUpdateTime; + const delayMs = Math.max(0, maxLatencyMs - timeSinceLastUpdate); + + if (this.scheduled) { + // A more urgent event (e.g. text after tools) must pull the flush earlier. + if (this.scheduledDelayMs !== null && delayMs < this.scheduledDelayMs) { + this.cancelScheduledFlush(); + } else { + return; + } } + + this.armFlush(delayMs); } private flush(): void { @@ -196,16 +257,14 @@ export class EventBatcher { } flushNow(): void { - if (this.frameId !== null) { - cancelAnimationFrame(this.frameId); - this.frameId = null; - } - if (this.timeoutId !== null) { - clearTimeout(this.timeoutId); - this.timeoutId = null; - } - this.scheduled = false; + const hadBufferedEvents = this.buffer.size > 0; + this.cancelScheduledFlush(); this.flush(); + // Keep the throttle baseline in sync so the next event is not flushed + // earlier than its latency budget allows. + if (hadBufferedEvents) { + this.lastUpdateTime = nowMs(); + } } getBufferSize(): number { @@ -213,16 +272,8 @@ export class EventBatcher { } clear(): void { - if (this.frameId !== null) { - cancelAnimationFrame(this.frameId); - this.frameId = null; - } - if (this.timeoutId !== null) { - clearTimeout(this.timeoutId); - this.timeoutId = null; - } + this.cancelScheduledFlush(); this.buffer.clear(); - this.scheduled = false; } destroy(): void { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 754624b26c..a3ac539534 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -8,10 +8,11 @@ import { stateMachineManager } from '../../state-machine'; import { SessionExecutionEvent, SessionExecutionState } from '../../state-machine/types'; import { agenticEventListener, type AgenticEventCallbacks } from '../AgenticEventListener'; import { - generateTextChunkKey, + generateTextChunkKey, generateToolEventKey, normalizeParamsPartialFragment, parseEventKey, + TEXT_CHUNK_MAX_LATENCY_MS, type FlowToolEvent, type SubagentParentInfo, type TextChunkEventData, @@ -1658,7 +1659,8 @@ function handleTextChunk(context: FlowChatContext, event: any): void { ...existing, text: existing.text + incoming.text, isThinkingEnd: existing.isThinkingEnd || incoming.isThinkingEnd - }) + }), + { maxLatencyMs: TEXT_CHUNK_MAX_LATENCY_MS } ); } diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts index 461638b0a7..b65a4e9060 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts @@ -654,7 +654,7 @@ describe('sessionToVirtualItems explore grouping', () => { }); }); - it('splits completed large model rounds into stable virtual chunks', () => { + it('splits completed large non-tail model rounds into stable virtual chunks', () => { const largeRound = makeRound({ id: 'large-round', items: makeTextItems(25, 'large-text'), @@ -662,6 +662,13 @@ describe('sessionToVirtualItems explore grouping', () => { isComplete: true, status: 'completed', }); + const trailingRound = makeRound({ + id: 'tail-round', + items: makeTextItems(2, 'tail-text'), + isStreaming: false, + isComplete: true, + status: 'completed', + }); const session = makeSession({ sessionId: 'large-round-session', dialogTurns: [{ @@ -672,7 +679,7 @@ describe('sessionToVirtualItems explore grouping', () => { content: 'Summarize a large trace', timestamp: 900, }, - modelRounds: [largeRound], + modelRounds: [largeRound, trailingRound], status: 'completed', startTime: 900, }], @@ -680,20 +687,11 @@ describe('sessionToVirtualItems explore grouping', () => { const items = sessionToVirtualItems(session); const modelItems = items.filter((item): item is ModelRoundVirtualItem => item.type === 'model-round'); + const largeChunks = modelItems.filter(item => item.sourceRoundId === 'large-round' || item.data.id.startsWith('large-round')); - expect(items.map(item => item.type)).toEqual([ - 'user-message', - 'model-round', - 'model-round', - 'model-round', - 'model-round', - 'model-round', - 'model-round', - 'model-round', - ]); - expect(modelItems.map(item => item.segmentIndex)).toEqual([0, 1, 2, 3, 4, 5, 6]); - expect(modelItems.map(item => item.segmentCount)).toEqual([7, 7, 7, 7, 7, 7, 7]); - expect(modelItems.map(item => item.sourceRoundId)).toEqual([ + expect(largeChunks.map(item => item.segmentIndex)).toEqual([0, 1, 2, 3, 4, 5, 6]); + expect(largeChunks.map(item => item.segmentCount)).toEqual([7, 7, 7, 7, 7, 7, 7]); + expect(largeChunks.map(item => item.sourceRoundId)).toEqual([ 'large-round', 'large-round', 'large-round', @@ -702,7 +700,7 @@ describe('sessionToVirtualItems explore grouping', () => { 'large-round', 'large-round', ]); - expect(modelItems.map(item => item.data.id)).toEqual([ + expect(largeChunks.map(item => item.data.id)).toEqual([ 'large-round:segment:0', 'large-round:segment:1', 'large-round:segment:2', @@ -711,10 +709,51 @@ describe('sessionToVirtualItems explore grouping', () => { 'large-round:segment:5', 'large-round:segment:6', ]); - expect(modelItems.map(item => item.data.items.length)).toEqual([4, 4, 4, 4, 4, 4, 1]); - expect(modelItems.map(item => item.isLastRound)).toEqual([false, false, false, false, false, false, true]); - expect(modelItems[0].data.items[0]?.id).toBe('large-text-1'); - expect(modelItems[6].data.items[0]?.id).toBe('large-text-25'); + expect(largeChunks.map(item => item.data.items.length)).toEqual([4, 4, 4, 4, 4, 4, 1]); + expect(largeChunks.every(item => item.isLastRound === false)).toBe(true); + expect(largeChunks[0].data.items[0]?.id).toBe('large-text-1'); + expect(largeChunks[6].data.items[0]?.id).toBe('large-text-25'); + expect(modelItems[modelItems.length - 1]).toMatchObject({ + data: { id: 'tail-round' }, + isLastRound: true, + segmentId: undefined, + }); + }); + + it('does not split the turn-tail large round (avoids completion remount flash)', () => { + const largeRound = makeRound({ + id: 'large-tail-round', + items: makeTextItems(25, 'large-tail-text'), + isStreaming: false, + isComplete: true, + status: 'completed', + }); + const session = makeSession({ + sessionId: 'large-tail-round-session', + dialogTurns: [{ + id: 'turn-1', + sessionId: 'large-tail-round-session', + userMessage: { + id: 'user-1', + content: 'Summarize a large trace', + timestamp: 900, + }, + modelRounds: [largeRound], + status: 'completed', + startTime: 900, + }], + }); + + const items = sessionToVirtualItems(session); + const modelItems = items.filter((item): item is ModelRoundVirtualItem => item.type === 'model-round'); + + expect(modelItems).toHaveLength(1); + expect(modelItems[0]).toMatchObject({ + data: { id: 'large-tail-round' }, + isLastRound: true, + segmentId: undefined, + }); + expect(modelItems[0].data.items).toHaveLength(25); }); it('does not split active or streaming large model rounds', () => { diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts index 6d92b6b692..1a64e5757d 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -208,7 +208,15 @@ function shouldSplitModelRoundForVirtualItems( round: ModelRound, isTurnComplete: boolean, nowMs: number, + isLastRound: boolean, ): boolean { + // Never split the turn-tail round on completion: replacing one Virtuoso key + // with N segment keys remounts the visible assistant message and flashes the + // chat pane. Older non-tail rounds may still split for virtualization. + if (isLastRound) { + return false; + } + return ( isTurnComplete && isTerminalRoundStatus(round.status) && @@ -225,8 +233,9 @@ function splitModelRoundForVirtualItems( round: ModelRound, isTurnComplete: boolean, nowMs: number, + isLastRound: boolean, ): ModelRoundVirtualChunk[] { - if (!shouldSplitModelRoundForVirtualItems(round, isTurnComplete, nowMs)) { + if (!shouldSplitModelRoundForVirtualItems(round, isTurnComplete, nowMs, isLastRound)) { return [{ round }]; } @@ -541,7 +550,7 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { groupIndex++; } else { const isLastRound = roundIndex === rounds.length - 1; - const roundChunks = splitModelRoundForVirtualItems(round, isTurnComplete, nowMs); + const roundChunks = splitModelRoundForVirtualItems(round, isTurnComplete, nowMs, isLastRound); roundChunks.forEach((chunk, chunkIndex) => { items.push({ type: 'model-round', diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx index 403876de05..ce49bfa59c 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx @@ -12,6 +12,7 @@ import { ChevronRight } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { FlowThinkingItem } from '../types/flow-chat'; import { useTypewriter } from '../hooks/useTypewriter'; +import { useReportTypewriterReveal } from '../hooks/TypewriterRevealGate'; import { useToolCardHeightContract } from './useToolCardHeightContract'; import { Markdown } from '@/component-library/components/Markdown/Markdown'; import './ModelThinkingDisplay.scss'; @@ -38,7 +39,8 @@ export const ModelThinkingDisplay: React.FC = ({ const touchScrollStartYRef = useRef(null); const isActive = isStreaming || status === 'streaming'; - const displayContent = useTypewriter(content, isActive); + const { displayText: displayContent, isRevealing } = useTypewriter(content, isActive); + useReportTypewriterReveal(thinkingItem.id, isRevealing); const shouldDefaultExpanded = displayContext === 'subagent-projection' ? isActive || isLastItem @@ -74,7 +76,14 @@ export const ModelThinkingDisplay: React.FC = ({ } }, [applyExpandedState, isExpanded, shouldDefaultExpanded]); - const renderedContent = isActive ? displayContent : content; + // Keep rendering the typewriter output while it drains after the stream + // ends. Snapping to full `content` here would make the drain invisible + // while `isRevealing` still holds the reveal gate, delaying the round + // footer for no visible reason. + const renderedContent = isRevealing ? displayContent : content; + // Cover the whole reveal with Markdown streaming mode so the Prism upgrade + // does not land mid-drain. + const isVisuallyStreaming = isActive || isRevealing; const getThinkingScrollGap = useCallback((el: HTMLElement) => ( el.scrollHeight - el.scrollTop - el.clientHeight @@ -278,7 +287,7 @@ export const ModelThinkingDisplay: React.FC = ({ >