Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ interface AsyncPrismSyntaxHighlighterProps {
lineNumberStyle?: React.CSSProperties;
fallback?: React.ComponentType<FlowCodeBlockFallbackProps>;
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;
}
Expand Down Expand Up @@ -59,6 +65,7 @@ export const AsyncPrismSyntaxHighlighter: React.FC<AsyncPrismSyntaxHighlighterPr
lineNumberStyle,
fallback: Fallback,
fallbackProps,
preferFallback = false,
traceContext,
children,
}) => {
Expand Down Expand Up @@ -140,7 +147,7 @@ export const AsyncPrismSyntaxHighlighter: React.FC<AsyncPrismSyntaxHighlighterPr
/>
) : null;

if (!Highlighter) {
if (!Highlighter || preferFallback) {
if (Fallback && fallbackProps) {
return (
<>
Expand Down
37 changes: 16 additions & 21 deletions src/web-ui/src/component-library/components/Markdown/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -797,6 +797,11 @@ export const Markdown = React.memo<MarkdownProps>(({
}) => {
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]);

Expand Down Expand Up @@ -1067,11 +1072,13 @@ export const Markdown = React.memo<MarkdownProps>(({
);
}

const streaming = isStreamingRef.current;

if (language.toLowerCase().startsWith('mermaid')) {
return (
<MermaidBlock
code={code}
isStreaming={isStreaming}
isStreaming={streaming}
/>
);
}
Expand All @@ -1097,23 +1104,12 @@ export const Markdown = React.memo<MarkdownProps>(({
<CopyButton code={code} />
</div>
<div className="code-block-body">
{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
// <pre> 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.
<CodeBlockFallback
code={code}
language={normalizedLang}
bodyStyle={codeBodyStyle}
codeTagStyle={codeTagStyle}
gutterColor={gutterColor}
/>
) : (
{/*
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).
*/}
<AsyncPrismSyntaxHighlighter
language={normalizedLang}
style={syntaxTheme}
Expand All @@ -1127,6 +1123,7 @@ export const Markdown = React.memo<MarkdownProps>(({
userSelect: 'none',
minWidth: '3em'
}}
preferFallback={streaming}
fallback={CodeBlockFallback}
fallbackProps={{
code,
Expand All @@ -1139,7 +1136,6 @@ export const Markdown = React.memo<MarkdownProps>(({
>
{code}
</AsyncPrismSyntaxHighlighter>
)}
</div>
</div>
);
Expand Down Expand Up @@ -1387,7 +1383,6 @@ export const Markdown = React.memo<MarkdownProps>(({
basePath,
remoteConnectionId,
expandDetailsByDefault,
isStreaming,
markdownContent,
handleFileViewRequest,
handleRevealInExplorer,
Expand Down
13 changes: 9 additions & 4 deletions src/web-ui/src/flow_chat/components/ChatInput.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
52 changes: 36 additions & 16 deletions src/web-ui/src/flow_chat/components/FlowTextBlock.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -87,9 +87,34 @@ export const FlowTextBlock = React.memo<FlowTextBlockProps>(({

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);
Expand Down Expand Up @@ -124,9 +149,9 @@ export const FlowTextBlock = React.memo<FlowTextBlockProps>(({
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) {
Expand All @@ -146,22 +171,17 @@ export const FlowTextBlock = React.memo<FlowTextBlockProps>(({
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 ? (
<MarkdownRenderer
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 <pre> / 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}
Expand Down
8 changes: 8 additions & 0 deletions src/web-ui/src/flow_chat/components/RichTextInput.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
25 changes: 23 additions & 2 deletions src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -24,7 +39,7 @@
}
}

// Complete state
// Complete state — keep opacity stable; do not attach an animation here.
&--complete {
opacity: 1;
}
Expand All @@ -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 {
Expand Down
Loading
Loading