Skip to content

Commit 0a945ed

Browse files
committed
phase 3: compact-on-append parts + drop deltas[] for content prop on typewriter
1 parent 85fb141 commit 0a945ed

5 files changed

Lines changed: 50 additions & 28 deletions

File tree

src/browser/features/Messages/AssistantMessage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ 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}

src/browser/features/Messages/ReasoningMessage.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,13 @@ export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({
129129
// stream end would unmount/remount the markdown subtree and visibly flash the
130130
// content. isComplete={!isStreaming} cleanly bypasses the smoothing engine once
131131
// 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+
132136
return (
133137
<TypewriterMarkdown
134-
deltas={[normalizeReasoningMarkdown(content)]}
138+
content={normalizedContent}
135139
isComplete={!isStreaming}
136140
preserveLineBreaks
137141
streamKey={message.historyId}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ describe("TypewriterMarkdown", () => {
7777

7878
const view = render(
7979
<TypewriterMarkdown
80-
deltas={["Hello world"]}
80+
content="Hello world"
8181
isComplete={false}
8282
streamKey="msg-1"
8383
streamSource="live"
@@ -97,7 +97,7 @@ describe("TypewriterMarkdown", () => {
9797
test("bypasses smoothing for replay streams", () => {
9898
render(
9999
<TypewriterMarkdown
100-
deltas={["Replayed content"]}
100+
content="Replayed content"
101101
isComplete={false}
102102
streamKey="msg-2"
103103
streamSource="replay"

src/browser/features/Messages/TypewriterMarkdown.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import React, { useMemo } from "react";
1+
import React from "react";
22
import { useSmoothStreamingText } from "@/browser/hooks/useSmoothStreamingText";
33
import { useWorkspaceStreamingStats } from "@/browser/stores/WorkspaceStore";
44
import { cn } from "@/common/lib/utils";
55
import { MarkdownCore } from "./MarkdownCore";
66
import { StreamingContext } from "./StreamingContext";
77

88
interface TypewriterMarkdownProps {
9-
deltas: string[];
9+
/** Full text to render. During streaming this grows monotonically. */
10+
content: string;
1011
isComplete: boolean;
1112
className?: string;
1213
/**
@@ -28,18 +29,19 @@ interface TypewriterMarkdownProps {
2829
workspaceId?: string;
2930
}
3031

31-
// Use React.memo to prevent unnecessary re-renders from parent
32-
export const TypewriterMarkdown = React.memo<TypewriterMarkdownProps>(function TypewriterMarkdown({
33-
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,
3437
isComplete,
3538
className,
3639
preserveLineBreaks,
3740
streamKey,
3841
streamSource = "live",
3942
workspaceId,
40-
}) {
41-
const fullContent = deltas.join("");
42-
const isStreaming = !isComplete && fullContent.length > 0;
43+
}) => {
44+
const isStreaming = !isComplete && content.length > 0;
4345

4446
// Read the live model emission rate (chars/sec) for the active stream of this
4547
// workspace. The hook subscribes to its own MapStore so per-delta updates
@@ -49,18 +51,19 @@ export const TypewriterMarkdown = React.memo<TypewriterMarkdownProps>(function T
4951
const streamingStats = useWorkspaceStreamingStats(workspaceId ?? "");
5052
const liveCharsPerSec = isStreaming && workspaceId ? (streamingStats?.charsPerSec ?? 0) : 0;
5153

52-
// Two-clock streaming: ingestion (fullContent) vs presentation (visibleText).
54+
// Two-clock streaming: ingestion (content) vs presentation (visibleText).
5355
// The jitter buffer reveals text at a steady cadence instead of bursty token clumps.
5456
// Replay and completed streams bypass smoothing entirely.
5557
const { visibleText } = useSmoothStreamingText({
56-
fullText: fullContent,
58+
fullText: content,
5759
isStreaming,
5860
bypassSmoothing: streamSource === "replay",
5961
streamKey: streamKey ?? "",
6062
liveCharsPerSec,
6163
});
6264

63-
const streamingContextValue = useMemo(() => ({ isStreaming }), [isStreaming]);
65+
// React Compiler memoizes this object; no manual useMemo needed.
66+
const streamingContextValue = { isStreaming };
6467

6568
return (
6669
<StreamingContext.Provider value={streamingContextValue}>
@@ -73,4 +76,4 @@ export const TypewriterMarkdown = React.memo<TypewriterMarkdownProps>(function T
7376
</div>
7477
</StreamingContext.Provider>
7578
);
76-
});
79+
};

src/browser/utils/messages/StreamingMessageAggregator.ts

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1898,12 +1898,22 @@ export class StreamingMessageAggregator {
18981898
}
18991899
}
19001900

1901-
// Append each delta as a new part (merging happens at display time)
1902-
message.parts.push({
1903-
type: "text",
1904-
text: data.delta,
1905-
timestamp: data.timestamp,
1906-
});
1901+
// Compact-on-append: when the previous part is text (the common case during
1902+
// a text run), append into it in place instead of growing parts unbounded.
1903+
// For a 10k-char reply this drops parts.length from thousands to one and
1904+
// shrinks per-render mergeAdjacentParts cost from O(N) to O(1). The on-disk
1905+
// format is unaffected — partial.json/chat.jsonl persistence happens
1906+
// backend-side; this aggregator's parts are pure in-memory display state.
1907+
const lastPart = message.parts[message.parts.length - 1];
1908+
if (lastPart?.type === "text") {
1909+
lastPart.text += data.delta;
1910+
} else {
1911+
message.parts.push({
1912+
type: "text",
1913+
text: data.delta,
1914+
timestamp: data.timestamp,
1915+
});
1916+
}
19071917

19081918
// Track delta for token counting and TPS calculation
19091919
this.trackDelta(data.messageId, data.tokens, data.timestamp, "text");
@@ -2459,12 +2469,17 @@ export class StreamingMessageAggregator {
24592469
}
24602470
}
24612471

2462-
// Append each delta as a new part (merging happens at display time)
2463-
message.parts.push({
2464-
type: "reasoning",
2465-
text: data.delta,
2466-
timestamp: data.timestamp,
2467-
});
2472+
// Compact-on-append for reasoning runs (same rationale as handleStreamDelta).
2473+
const lastPart = message.parts[message.parts.length - 1];
2474+
if (lastPart?.type === "reasoning") {
2475+
lastPart.text += data.delta;
2476+
} else {
2477+
message.parts.push({
2478+
type: "reasoning",
2479+
text: data.delta,
2480+
timestamp: data.timestamp,
2481+
});
2482+
}
24682483

24692484
// Track delta for token counting and TPS calculation
24702485
this.trackDelta(data.messageId, data.tokens, data.timestamp, "reasoning");

0 commit comments

Comments
 (0)