Skip to content

Commit af4912e

Browse files
authored
🤖 perf: smooth text streaming (kill cascade re-renders, model-aware reveal) (#3219)
## Summary Streamed assistant text (and reasoning) was visibly jittery — periodic catch-up jumps every few seconds, rate stuck at ~72 chars/sec regardless of what the model emitted, and a sub-frame of work for the entire chat list on every delta. This PR makes the cadence smooth in three ordered fixes plus a TPS-display fix discovered during review: leaf-subscribe the streaming-stats pill so it stops invalidating `WorkspaceState`, replace the smoothing engine's hard-snap with a model-aware soft catch-up, compact streaming parts on append, and floor the TPS calculator's time span so a new stream's first deltas don't spike the displayed rate. ## Background The renderer has had a two-clock smoothing model (`SmoothTextEngine` + `useSmoothStreamingText`) for a while, but several regressions defeated it: 1. `WorkspaceState.streamingTokenCount` / `streamingTPS` were computed inside the `getWorkspaceState` snapshot using `Date.now()`. Every coalesced delta produced a new snapshot reference, which cascaded `WorkspaceShell → ChatPane → MessageRenderer` through every row. `useDeferredValue` was bypassed for the entire stream by `shouldBypassDeferredMessages`, so reconciliation ran at the ingestion rate. 2. `getAdaptiveRate(backlog)` ignored the model's actual emission rate. With a fast model (~120 cps) and `BASE_CHARS_PER_SEC=72`, the visible cursor fell behind by ~5 chars per ingestion cycle until backlog crossed `MAX_VISUAL_LAG_CHARS=120`, at which point `enforceMaxVisualLag` snapped `visible := full - 120` and zeroed the budget — that snap is exactly the visible "catch-up jump". 3. `requestIdleCallback({ timeout: 100 })` was used for streaming deltas. The smoothing engine should be the only pacing layer; idle batching just feeds (2). 4. `handleStreamDelta` appended a fresh `{ type: "text" }` part per chunk; `mergeAdjacentParts` re-merged on every render. For a 10k-char reply that's tens of thousands of merges per turn. 5. `calculateTPS` divided by `now - firstDelta.timestamp`. With one delta that span is typically a few milliseconds, so e.g. `50 tokens / 0.005s = 10000 t/s`. Phase 1's microtask cadence exposed this — where the prior idle-callback batching used to mask it by sampling later — and Phase 2 wired TPS into the smoothing engine, amplifying its visibility. ## Implementation Four commits, ordered so each phase is verifiable in isolation: **Phase 1 — leaf-subscribe streaming stats, microtask ingestion (`775e9023c`)** - Removed `streamingTokenCount` / `streamingTPS` from `WorkspaceState`. - Added `WorkspaceStreamingStats` + `streamingStatsStore` (`MapStore`) + `useWorkspaceStreamingStats(workspaceId)` leaf hook (mirrors the existing `useWorkspaceStatsSnapshot` pattern at `WorkspaceStore.ts:4127`). - Replaced `scheduleIdleStateBump` with `scheduleStreamingStateBump` for streaming delta types (`stream-delta`, `tool-call-delta`, `reasoning-delta`). It coalesces on `queueMicrotask` instead of an idle callback. `init-output` and `bash-output` keep the idle path (terminal-style throughput). - Wired `cancelPendingStreamingBump` into stream-end / stream-abort / replay reset / `removeWorkspace`. - `StreamingBarrier` now reads via the leaf hook. **Phase 2 — model-aware smoothing engine, soft catch-up (`85fb141da`)** - `SmoothTextEngine.update()` accepts an optional `liveCharsPerSec`. `getAdaptiveRate(backlog, liveCps)` combines a steady-state floor (`max(BASE, liveCps)`), a soft catch-up ramp that drains lag over `SOFT_CATCHUP_DRAIN_MS` once it exceeds `SOFT_CATCHUP_LAG_CHARS=60`, and the legacy backlog-pressure ramp (kept as upper bound). - Replaced the hard-snap discontinuity with the soft ramp. `MAX_VISUAL_LAG_CHARS` is now 1024 (was 120) — a defensive safety net for paused-tab pathological bursts that normal streams never hit. - Bumped `MIN_FRAME_CHARS` from 1 to 2 so reveals coalesce to ~30 Hz at the BASE rate (half the markdown re-parse cost; humans can't see the difference). Tail-end reveal still works because the gate is now `min(MIN_FRAME_CHARS, backlog)`. - `useSmoothStreamingText` and `TypewriterMarkdown` thread `liveCharsPerSec` through; `TypewriterMarkdown` accepts a new `workspaceId` prop, forwarded from `AssistantMessage` and `ReasoningMessage` (via `MessageRenderer`). **Phase 3 — compact-on-append, clean prop surface (`0a945ed7b`)** - `StreamingMessageAggregator.handleStreamDelta` / `handleReasoningDelta` append into the previous adjacent text/reasoning part in place. For a 10k-char reply this drops `parts.length` from thousands to one and `mergeAdjacentParts` cost from O(N) to O(1). Backend persistence (`partial.json`, `chat.jsonl`) is unaffected — those writers live backend-side; this aggregator's `parts` is pure display state. - `TypewriterMarkdown`: dropped the `deltas: string[]` shape (always passed as `[content]` literal — defeated `React.memo`) for `content: string`. Removed the manual `React.memo` and the inner `useMemo` for the streaming-context value (React Compiler handles both). **Phase 4 — TPS calculator floor + stream-error token cleanup (`a476613be`)** - `calculateTPS` now floors the divisor at `MIN_TPS_TIME_SPAN_MS = 1000`. With one delta the rate becomes `tokens / 1s` instead of `tokens / 0.005s`. The reported TPS smoothly ramps up over the first second of a stream instead of spiking and "dropping abruptly". Slight under-statement during the settling window is the trade-off — strictly preferable to an order-of-magnitude over-statement. - The `stream-error` branch in `applyWorkspaceChatEventToAggregator` now calls `clearTokenState`, matching `stream-end` and `stream-abort`. Without it, the errored message's `deltaHistory` entry leaks into a follow-up stream's TPS calculation. ## Validation - `make typecheck` ✅ - `make lint` ✅ - Targeted streaming surface: 1009+ tests pass / 0 fail across `SmoothTextEngine`, `useSmoothStreamingText`, `StreamingMessageAggregator`, `applyWorkspaceChatEventToAggregator`, `StreamingTPSCalculator`, `TypewriterMarkdown`, `ReasoningMessage`, `StreamingBarrier{,View}`, `PinnedTodoList`, `WorkspaceStore`, plus the broader `src/browser/utils/messages/`, `src/browser/features/Messages/`, `src/browser/stores/`, and `src/browser/hooks/` suites. - New behavioral tests: - `SmoothTextEngine.test.ts`: rate tracks `liveCharsPerSec`; soft catch-up engaged for 60–1024 char lags without snap; hard snap still fires above the safety threshold. - `StreamingTPSCalculator.test.ts`: 1s floor applied for tiny / zero spans; raw span used once it exceeds the floor; negative spans (clock skew) return 0. - `applyWorkspaceChatEventToAggregator.test.ts`: `stream-error` calls `clearTokenState`. ## Risks Localized to the streaming display path; no protocol or persistence changes. - **Re-render shape (Phase 1).** Streaming deltas now bump `WorkspaceState` once per microtask drain instead of once per `requestIdleCallback`. Net effect under heavy load is *less* work because the snapshot stops invalidating per-delta TPS, but it's a behavioral shift — verified via the existing 106-test `WorkspaceStore` suite plus targeted `StreamingBarrier` tests. - **Smoothing engine constants (Phase 2).** `MAX_VISUAL_LAG_CHARS` jumped 120 → 1024 and `MIN_FRAME_CHARS` 1 → 2. Existing test "caps visual lag when incoming text jumps ahead" still passes against the new soft-ramp behavior, and the new "hard-snaps when lag exceeds the safety threshold" test confirms the safety net still functions. - **Compact-on-append (Phase 3).** Touches the in-memory `parts` array shape during streaming. The aggregator already had compaction at stream-end (`compactMessageParts`); we're just doing it eagerly. No on-disk format change. All `StreamingMessageAggregator` and `applyWorkspaceChatEventToAggregator` tests pass. - **TPS floor (Phase 4).** The reported rate during the first second of a stream now under-counts versus the previous (mathematically broken) value. Backend `sessionTimingService` also calls `calculateTPS`; same floor applies there but the backend's window is broader so the visible effect is smaller. No risk to persisted usage / cost calculations — those use `usage.outputTokens / duration` from the API, not the streaming TPS estimator. --- _Generated with `mux` • Model: `anthropic:claude-opus-4-7` • Thinking: `xhigh` • Cost: `$23.55`_ <!-- mux-attribution: model=anthropic:claude-opus-4-7 thinking=xhigh costs=23.55 -->
1 parent 80ed51e commit af4912e

19 files changed

Lines changed: 616 additions & 85 deletions

‎src/browser/components/PinnedTodoList/PinnedTodoList.test.tsx‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,6 @@ function buildWorkspaceState(workspaceId: string, state: MockWorkspaceState): Wo
5252
pendingStreamModel: null,
5353
runtimeStatus: null,
5454
autoRetryStatus: null,
55-
streamingTokenCount: undefined,
56-
streamingTPS: undefined,
5755
};
5856
}
5957

‎src/browser/features/Messages/AssistantMessage.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,11 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
184184
// renders parseIncompleteMarkdown=false, matching the prior static render exactly.
185185
const contentElement = (
186186
<TypewriterMarkdown
187-
deltas={[content]}
187+
content={content}
188188
isComplete={!isStreaming}
189189
streamKey={message.historyId}
190190
streamSource={message.streamPresentation?.source}
191+
workspaceId={workspaceId}
191192
/>
192193
);
193194

‎src/browser/features/Messages/ChatBarrier/StreamingBarrier.test.tsx‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ interface MockWorkspaceState {
1313
pendingStreamStartTime: number | null;
1414
pendingStreamModel: string | null;
1515
runtimeStatus: { phase: string; detail?: string } | null;
16-
streamingTokenCount: number | undefined;
17-
streamingTPS: number | undefined;
1816
}
1917

2018
function createWorkspaceState(overrides: Partial<MockWorkspaceState> = {}): MockWorkspaceState {
@@ -27,8 +25,6 @@ function createWorkspaceState(overrides: Partial<MockWorkspaceState> = {}): Mock
2725
pendingStreamStartTime: null,
2826
pendingStreamModel: null,
2927
runtimeStatus: null,
30-
streamingTokenCount: undefined,
31-
streamingTPS: undefined,
3228
...overrides,
3329
};
3430

@@ -39,10 +35,17 @@ function createWorkspaceState(overrides: Partial<MockWorkspaceState> = {}): Mock
3935
return state;
4036
}
4137

38+
interface MockStreamingStats {
39+
tokenCount: number;
40+
tps: number;
41+
charsPerSec: number;
42+
}
43+
4244
const STATUS_DISPLAY_DELAY_MS = 1000;
4345
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
4446

4547
let currentWorkspaceState = createWorkspaceState();
48+
let currentStreamingStats: MockStreamingStats | null = null;
4649
let hasInterruptingStream = false;
4750
const setInterrupting = mock((_workspaceId: string) => undefined);
4851
const interruptStream = mock((_input: unknown) =>
@@ -70,6 +73,7 @@ void mock.module("@/browser/stores/WorkspaceStore", () => ({
7073
useWorkspaceStoreRaw: () => ({
7174
setInterrupting,
7275
}),
76+
useWorkspaceStreamingStats: () => currentStreamingStats,
7377
}));
7478

7579
void mock.module("@/browser/contexts/API", () => ({
@@ -119,6 +123,7 @@ describe("StreamingBarrier", () => {
119123
globalThis.document = globalThis.window.document;
120124

121125
currentWorkspaceState = createWorkspaceState();
126+
currentStreamingStats = null;
122127
hasInterruptingStream = false;
123128
setInterrupting.mockClear();
124129
interruptStream.mockClear();
@@ -289,9 +294,8 @@ describe("StreamingBarrier", () => {
289294
currentWorkspaceState = createWorkspaceState({
290295
canInterrupt: true,
291296
currentModel: "anthropic:claude-opus-4-6",
292-
streamingTokenCount: 42,
293-
streamingTPS: 18,
294297
});
298+
currentStreamingStats = { tokenCount: 42, tps: 18, charsPerSec: 72 };
295299
view.rerender(<StreamingBarrier workspaceId="ws-1" />);
296300

297301
expect(view.getByText("claude-opus-4-6 streaming...")).toBeTruthy();

‎src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
useWorkspaceState,
1010
useWorkspaceAggregator,
1111
useWorkspaceStoreRaw,
12+
useWorkspaceStreamingStats,
1213
} from "@/browser/stores/WorkspaceStore";
1314
import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings";
1415
import { useSettings } from "@/browser/contexts/SettingsContext";
@@ -146,6 +147,9 @@ export const StreamingBarrier: React.FC<StreamingBarrierProps> = ({
146147
const workspaceState = useWorkspaceState(workspaceId);
147148
const aggregator = useWorkspaceAggregator(workspaceId);
148149
const storeRaw = useWorkspaceStoreRaw();
150+
// Subscribe directly to live streaming stats (token count + TPS) so per-delta
151+
// updates re-render this leaf only, not the entire ChatPane subtree.
152+
const streamingStats = useWorkspaceStreamingStats(workspaceId);
149153
const { api } = useAPI();
150154
const { open: openSettings } = useSettings();
151155

@@ -172,9 +176,10 @@ export const StreamingBarrier: React.FC<StreamingBarrierProps> = ({
172176
// Only show token count during active streaming/compacting
173177
const showTokenCount = phase === "streaming" || phase === "compacting";
174178

175-
// Get live streaming stats from workspace state (updated on each stream-delta)
176-
const tokenCount = showTokenCount ? workspaceState.streamingTokenCount : undefined;
177-
const tps = showTokenCount ? workspaceState.streamingTPS : undefined;
179+
// Live streaming stats come from a leaf subscription so per-delta updates
180+
// don't cascade through the full chat subtree.
181+
const tokenCount = showTokenCount ? streamingStats?.tokenCount : undefined;
182+
const tps = showTokenCount ? streamingStats?.tps : undefined;
178183

179184
// Model to display:
180185
// - "starting" phase: prefer pendingStreamModel (from muxMetadata), then localStorage

‎src/browser/features/Messages/MessageRenderer.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,9 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
121121
);
122122
break;
123123
case "reasoning":
124-
renderedMessage = <ReasoningMessage message={message} className={className} />;
124+
renderedMessage = (
125+
<ReasoningMessage message={message} className={className} workspaceId={workspaceId} />
126+
);
125127
break;
126128
case "stream-error":
127129
renderedMessage = <StreamErrorMessage message={message} className={className} />;

‎src/browser/features/Messages/ReasoningMessage.tsx‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import { Lightbulb } from "lucide-react";
99
interface ReasoningMessageProps {
1010
message: DisplayedMessage & { type: "reasoning" };
1111
className?: string;
12+
/**
13+
* Workspace this reasoning belongs to. Forwarded to TypewriterMarkdown so the
14+
* smoothing engine can target the model's live emission rate. Optional —
15+
* tests render this component without a workspace context.
16+
*/
17+
workspaceId?: string;
1218
}
1319

1420
const REASONING_FONT_CLASSES = "font-primary text-[12px] leading-[18px]";
@@ -40,7 +46,11 @@ function parseLeadingBoldSummary(
4046
};
4147
}
4248

43-
export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ message, className }) => {
49+
export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({
50+
message,
51+
className,
52+
workspaceId,
53+
}) => {
4454
const [isExpanded, setIsExpanded] = useState(message.isStreaming);
4555
// Track the height when expanded to reserve space during collapse transitions
4656
const [expandedHeight, setExpandedHeight] = useState<number | null>(null);
@@ -119,13 +129,18 @@ export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ message, cla
119129
// stream end would unmount/remount the markdown subtree and visibly flash the
120130
// content. isComplete={!isStreaming} cleanly bypasses the smoothing engine once
121131
// the stream ends, matching the prior static-render behavior.
132+
// React Compiler auto-memoizes this normalize call between renders that
133+
// share the same `content` value; no manual useMemo needed.
134+
const normalizedContent = normalizeReasoningMarkdown(content);
135+
122136
return (
123137
<TypewriterMarkdown
124-
deltas={[normalizeReasoningMarkdown(content)]}
138+
content={normalizedContent}
125139
isComplete={!isStreaming}
126140
preserveLineBreaks
127141
streamKey={message.historyId}
128142
streamSource={message.streamPresentation?.source}
143+
workspaceId={workspaceId}
129144
/>
130145
);
131146
};

‎src/browser/features/Messages/TypewriterMarkdown.test.tsx‎

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { UseSmoothStreamingTextOptions } from "@/browser/hooks/useSmoothStreamingText";
22
import { useSmoothStreamingText as importedUseSmoothStreamingText } from "@/browser/hooks/useSmoothStreamingText";
3+
import { useWorkspaceStreamingStats as importedUseWorkspaceStreamingStats } from "@/browser/stores/WorkspaceStore";
34
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
45
import { cleanup, render } from "@testing-library/react";
56
import { GlobalWindow } from "happy-dom";
@@ -8,6 +9,7 @@ import { TypewriterMarkdown } from "./TypewriterMarkdown";
89

910
const actualMarkdownCore = ImportedMarkdownCore;
1011
const actualUseSmoothStreamingText = importedUseSmoothStreamingText;
12+
const actualUseWorkspaceStreamingStats = importedUseWorkspaceStreamingStats;
1113

1214
const mockUseSmoothStreamingText = mock(
1315
(options: UseSmoothStreamingTextOptions): { visibleText: string; isCaughtUp: boolean } => ({
@@ -16,6 +18,8 @@ const mockUseSmoothStreamingText = mock(
1618
})
1719
);
1820

21+
const mockUseWorkspaceStreamingStats = mock((_workspaceId: string) => null);
22+
1923
function MarkdownCoreStub(props: { content: string }) {
2024
return <div data-testid="markdown-core">{props.content}</div>;
2125
}
@@ -29,6 +33,9 @@ async function installTypewriterMarkdownModuleMocks() {
2933
await mock.module("@/browser/hooks/useSmoothStreamingText", () => ({
3034
useSmoothStreamingText: mockUseSmoothStreamingText,
3135
}));
36+
await mock.module("@/browser/stores/WorkspaceStore", () => ({
37+
useWorkspaceStreamingStats: mockUseWorkspaceStreamingStats,
38+
}));
3239
}
3340

3441
async function restoreTypewriterMarkdownModuleMocks() {
@@ -40,6 +47,9 @@ async function restoreTypewriterMarkdownModuleMocks() {
4047
await mock.module("@/browser/hooks/useSmoothStreamingText", () => ({
4148
useSmoothStreamingText: actualUseSmoothStreamingText,
4249
}));
50+
await mock.module("@/browser/stores/WorkspaceStore", () => ({
51+
useWorkspaceStreamingStats: actualUseWorkspaceStreamingStats,
52+
}));
4353
}
4454

4555
describe("TypewriterMarkdown", () => {
@@ -59,6 +69,7 @@ describe("TypewriterMarkdown", () => {
5969
globalThis.document = globalThis.window.document;
6070
await installTypewriterMarkdownModuleMocks();
6171
mockUseSmoothStreamingText.mockClear();
72+
mockUseWorkspaceStreamingStats.mockClear();
6273
});
6374

6475
afterEach(async () => {
@@ -77,7 +88,7 @@ describe("TypewriterMarkdown", () => {
7788

7889
const view = render(
7990
<TypewriterMarkdown
80-
deltas={["Hello world"]}
91+
content="Hello world"
8192
isComplete={false}
8293
streamKey="msg-1"
8394
streamSource="live"
@@ -90,13 +101,14 @@ describe("TypewriterMarkdown", () => {
90101
isStreaming: true,
91102
bypassSmoothing: false,
92103
streamKey: "msg-1",
104+
liveCharsPerSec: 0,
93105
});
94106
});
95107

96108
test("bypasses smoothing for replay streams", () => {
97109
render(
98110
<TypewriterMarkdown
99-
deltas={["Replayed content"]}
111+
content="Replayed content"
100112
isComplete={false}
101113
streamKey="msg-2"
102114
streamSource="replay"
@@ -107,4 +119,38 @@ describe("TypewriterMarkdown", () => {
107119
expect.objectContaining({ bypassSmoothing: true })
108120
);
109121
});
122+
123+
// Regression: completed historical messages must not subscribe to live
124+
// streaming stats for their workspace, otherwise every assistant message in a
125+
// long transcript re-renders on every stream-delta of an active stream and
126+
// re-introduces the cascade jitter this PR is supposed to eliminate.
127+
test("completed messages subscribe with empty key (no live-stats updates)", () => {
128+
render(
129+
<TypewriterMarkdown
130+
content="Historical reply"
131+
isComplete={true}
132+
streamKey="msg-old"
133+
streamSource="live"
134+
workspaceId="ws-active"
135+
/>
136+
);
137+
138+
// Hook still runs (rules of hooks), but the key must be the no-op sentinel.
139+
expect(mockUseWorkspaceStreamingStats).toHaveBeenCalledWith("");
140+
expect(mockUseWorkspaceStreamingStats).not.toHaveBeenCalledWith("ws-active");
141+
});
142+
143+
test("streaming messages subscribe with the real workspace key", () => {
144+
render(
145+
<TypewriterMarkdown
146+
content="Streaming reply"
147+
isComplete={false}
148+
streamKey="msg-live"
149+
streamSource="live"
150+
workspaceId="ws-active"
151+
/>
152+
);
153+
154+
expect(mockUseWorkspaceStreamingStats).toHaveBeenCalledWith("ws-active");
155+
});
110156
});
Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import React, { useMemo } from "react";
1+
import React from "react";
22
import { useSmoothStreamingText } from "@/browser/hooks/useSmoothStreamingText";
3+
import { useWorkspaceStreamingStats } from "@/browser/stores/WorkspaceStore";
34
import { cn } from "@/common/lib/utils";
45
import { MarkdownCore } from "./MarkdownCore";
56
import { StreamingContext } from "./StreamingContext";
67

78
interface TypewriterMarkdownProps {
8-
deltas: string[];
9+
/** Full text to render. During streaming this grows monotonically. */
10+
content: string;
911
isComplete: boolean;
1012
className?: string;
1113
/**
@@ -18,31 +20,56 @@ interface TypewriterMarkdownProps {
1820
streamKey?: string;
1921
/** Whether this stream originated from live tokens or replay. Defaults to "live". */
2022
streamSource?: "live" | "replay";
23+
/**
24+
* Workspace this content belongs to. When provided, the smoothing engine is
25+
* fed the live model emission rate so the visible cursor tracks the model's
26+
* actual output rather than the constant BASE rate. Optional because some
27+
* surfaces (storybook, preview popovers) render markdown without a workspace.
28+
*/
29+
workspaceId?: string;
2130
}
2231

23-
// Use React.memo to prevent unnecessary re-renders from parent
24-
export const TypewriterMarkdown = React.memo<TypewriterMarkdownProps>(function TypewriterMarkdown({
25-
deltas,
32+
// React Compiler memoizes this component automatically based on prop changes;
33+
// no manual React.memo wrapper. The previous deltas: string[] shape forced a new
34+
// array literal on every parent render and defeated the memo anyway.
35+
export const TypewriterMarkdown: React.FC<TypewriterMarkdownProps> = ({
36+
content,
2637
isComplete,
2738
className,
2839
preserveLineBreaks,
2940
streamKey,
3041
streamSource = "live",
31-
}) {
32-
const fullContent = deltas.join("");
33-
const isStreaming = !isComplete && fullContent.length > 0;
42+
workspaceId,
43+
}) => {
44+
const isStreaming = !isComplete && content.length > 0;
45+
46+
// Read the live model emission rate (chars/sec) for the active stream of this
47+
// workspace. The hook subscribes to its own MapStore so per-delta updates
48+
// re-render this component WITHOUT cascading through the parent — see
49+
// useWorkspaceStreamingStats.
50+
//
51+
// Subscribe to the real workspace key ONLY while this message is actively
52+
// streaming. Completed historical messages subscribe to the stable empty-key
53+
// sentinel, which is never bumped — so a long transcript of finished
54+
// assistant messages does not re-render on every delta of a new stream.
55+
// (Hooks must run unconditionally; we toggle the key, not the call site.)
56+
const subscriptionKey = isStreaming && workspaceId ? workspaceId : "";
57+
const streamingStats = useWorkspaceStreamingStats(subscriptionKey);
58+
const liveCharsPerSec = isStreaming && workspaceId ? (streamingStats?.charsPerSec ?? 0) : 0;
3459

35-
// Two-clock streaming: ingestion (fullContent) vs presentation (visibleText).
60+
// Two-clock streaming: ingestion (content) vs presentation (visibleText).
3661
// The jitter buffer reveals text at a steady cadence instead of bursty token clumps.
3762
// Replay and completed streams bypass smoothing entirely.
3863
const { visibleText } = useSmoothStreamingText({
39-
fullText: fullContent,
64+
fullText: content,
4065
isStreaming,
4166
bypassSmoothing: streamSource === "replay",
4267
streamKey: streamKey ?? "",
68+
liveCharsPerSec,
4369
});
4470

45-
const streamingContextValue = useMemo(() => ({ isStreaming }), [isStreaming]);
71+
// React Compiler memoizes this object; no manual useMemo needed.
72+
const streamingContextValue = { isStreaming };
4673

4774
return (
4875
<StreamingContext.Provider value={streamingContextValue}>
@@ -55,4 +82,4 @@ export const TypewriterMarkdown = React.memo<TypewriterMarkdownProps>(function T
5582
</div>
5683
</StreamingContext.Provider>
5784
);
58-
});
85+
};

0 commit comments

Comments
 (0)