Skip to content

Commit cc51e30

Browse files
committed
fix: gate data-streaming on live stream source, not replay
Codex P2 on PR #3221: replay rows are emitted with isStreaming=true and streamPresentation.source="replay" while the backend rebuilds history on reconnect. Gating data-streaming on isStreaming alone would set the attribute and animate every block of every replayed message — exactly what we wanted to avoid. Compute isLiveStreaming = isStreaming && streamSource !== "replay" and gate the data-streaming attribute on that. Completed messages already have isStreaming=false, so the attribute is naturally absent in their case too. Test extended to assert all three cases: live → "true", replay → absent, completed → absent.
1 parent 94ae380 commit cc51e30

2 files changed

Lines changed: 44 additions & 17 deletions

File tree

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

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -156,23 +156,42 @@ describe("TypewriterMarkdown", () => {
156156

157157
// The data-streaming attribute is the gate for the per-block fade-in CSS rule
158158
// (see globals.css, .markdown-content[data-streaming="true"] .streamdown-root > *).
159-
// It must only be present on the wrapper while the message is actively streaming
160-
// — otherwise historical/replayed transcripts would re-trigger the animation
161-
// every time their content prop changes.
162-
test("sets data-streaming on the wrapper only while streaming", () => {
163-
const streaming = render(
164-
<TypewriterMarkdown content="Live" isComplete={false} streamKey="msg-anim-live" />
159+
// It must be present ONLY on live streaming messages. Completed messages and
160+
// replay rows must not match — replay rows are emitted with isStreaming=true
161+
// while the backend rebuilds history, so guarding on isStreaming alone would
162+
// re-animate every block of every historical message on reconnect.
163+
test("sets data-streaming only for live streams, not replay or completed", () => {
164+
const live = render(
165+
<TypewriterMarkdown
166+
content="Live"
167+
isComplete={false}
168+
streamKey="msg-anim-live"
169+
streamSource="live"
170+
/>
171+
);
172+
expect(live.container.querySelector(".markdown-content")?.getAttribute("data-streaming")).toBe(
173+
"true"
174+
);
175+
live.unmount();
176+
177+
// Replay row (still streaming, but source=replay) — must not animate.
178+
const replay = render(
179+
<TypewriterMarkdown
180+
content="Replayed"
181+
isComplete={false}
182+
streamKey="msg-anim-replay"
183+
streamSource="replay"
184+
/>
165185
);
166186
expect(
167-
streaming.container.querySelector(".markdown-content")?.getAttribute("data-streaming")
168-
).toBe("true");
169-
streaming.unmount();
187+
replay.container.querySelector(".markdown-content")?.getAttribute("data-streaming")
188+
).toBeNull();
189+
replay.unmount();
170190

191+
// Completed message — must not animate either.
171192
const completed = render(
172193
<TypewriterMarkdown content="Done" isComplete={true} streamKey="msg-anim-done" />
173194
);
174-
// Absent (not "false") on completed messages so the CSS selector
175-
// [data-streaming="true"] cannot match.
176195
expect(
177196
completed.container.querySelector(".markdown-content")?.getAttribute("data-streaming")
178197
).toBeNull();

src/browser/features/Messages/TypewriterMarkdown.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,14 +71,22 @@ export const TypewriterMarkdown: React.FC<TypewriterMarkdownProps> = ({
7171
// React Compiler memoizes this object; no manual useMemo needed.
7272
const streamingContextValue = { isStreaming };
7373

74+
// Gate per-block fade-in (see globals.css) on LIVE streams only. Replay rows
75+
// are emitted as isStreaming=true with streamSource="replay" while the
76+
// backend rebuilds history on reconnect (StreamingMessageAggregator emits
77+
// `streamPresentation: { source: "replay" }`); without this guard, every
78+
// block of every replayed message would animate on reconnect/load. Completed
79+
// messages also have isStreaming=false so the attribute is naturally absent.
80+
// Using `|| undefined` keeps the attribute *off the DOM* (not "false") so
81+
// the [data-streaming="true"] CSS selector simply cannot match.
82+
const isLiveStreaming = isStreaming && streamSource !== "replay";
83+
7484
return (
7585
<StreamingContext.Provider value={streamingContextValue}>
76-
{/*
77-
* data-streaming is the gate for per-block fade-in (see globals.css). The
78-
* `|| undefined` keeps the attribute absent on completed messages so the
79-
* CSS rule never matches them (and historical transcripts never animate).
80-
*/}
81-
<div className={cn("markdown-content", className)} data-streaming={isStreaming || undefined}>
86+
<div
87+
className={cn("markdown-content", className)}
88+
data-streaming={isLiveStreaming || undefined}
89+
>
8290
<MarkdownCore
8391
content={visibleText}
8492
parseIncompleteMarkdown={isStreaming}

0 commit comments

Comments
 (0)