Skip to content
Merged
9 changes: 8 additions & 1 deletion src/browser/components/Messages/AssistantMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,14 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({

// Streaming text gets typewriter effect
if (isStreaming) {
const contentElement = <TypewriterMarkdown deltas={[content]} isComplete={false} />;
const contentElement = (
<TypewriterMarkdown
deltas={[content]}
isComplete={false}
streamKey={message.historyId}
streamSource={message.streamPresentation?.source}
/>
);

// Wrap streaming compaction in special container
if (isStreamingCompaction) {
Expand Down
2 changes: 2 additions & 0 deletions src/browser/components/Messages/ReasoningMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ message, cla
deltas={[normalizeReasoningMarkdown(content)]}
isComplete={false}
preserveLineBreaks
streamKey={message.historyId}
streamSource={message.streamPresentation?.source}
/>
);
}
Expand Down
77 changes: 77 additions & 0 deletions src/browser/components/Messages/TypewriterMarkdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { UseSmoothStreamingTextOptions } from "@/browser/hooks/useSmoothStreamingText";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { cleanup, render } from "@testing-library/react";
import { GlobalWindow } from "happy-dom";

const mockUseSmoothStreamingText = mock(
(options: UseSmoothStreamingTextOptions): { visibleText: string; isCaughtUp: boolean } => ({
visibleText: options.fullText,
isCaughtUp: !options.isStreaming,
})
);

void mock.module("./MarkdownCore", () => ({
MarkdownCore: (props: { content: string }) => (
<div data-testid="markdown-core">{props.content}</div>
),
}));

void mock.module("@/browser/hooks/useSmoothStreamingText", () => ({
useSmoothStreamingText: mockUseSmoothStreamingText,
}));

import { TypewriterMarkdown } from "./TypewriterMarkdown";

describe("TypewriterMarkdown", () => {
beforeEach(() => {
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
globalThis.document = globalThis.window.document;
mockUseSmoothStreamingText.mockClear();
});

afterEach(() => {
cleanup();
mock.restore();
globalThis.window = undefined as unknown as Window & typeof globalThis;
globalThis.document = undefined as unknown as Document;
});

test("passes smoothed visible text to MarkdownCore when streaming", () => {
mockUseSmoothStreamingText.mockImplementationOnce(() => ({
visibleText: "Hel",
isCaughtUp: false,
}));

const view = render(
<TypewriterMarkdown
deltas={["Hello world"]}
isComplete={false}
streamKey="msg-1"
streamSource="live"
/>
);

expect(view.getByTestId("markdown-core").textContent).toBe("Hel");
expect(mockUseSmoothStreamingText).toHaveBeenCalledWith({
fullText: "Hello world",
isStreaming: true,
bypassSmoothing: false,
streamKey: "msg-1",
});
});

test("bypasses smoothing for replay streams", () => {
render(
<TypewriterMarkdown
deltas={["Replayed content"]}
isComplete={false}
streamKey="msg-2"
streamSource="replay"
/>
);

expect(mockUseSmoothStreamingText).toHaveBeenCalledWith(
expect.objectContaining({ bypassSmoothing: true })
);
});
});
24 changes: 19 additions & 5 deletions src/browser/components/Messages/TypewriterMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useMemo } from "react";
import { useSmoothStreamingText } from "@/browser/hooks/useSmoothStreamingText";
import { cn } from "@/common/lib/utils";
import { MarkdownCore } from "./MarkdownCore";
import { StreamingContext } from "./StreamingContext";
Expand All @@ -13,6 +14,10 @@ interface TypewriterMarkdownProps {
* are often intentional.
*/
preserveLineBreaks?: boolean;
/** Unique key for the current stream — reset smooth engine on change. */
streamKey?: string;
/** Whether this stream originated from live tokens or replay. Defaults to "live". */
streamSource?: "live" | "replay";
}

// Use React.memo to prevent unnecessary re-renders from parent
Expand All @@ -21,20 +26,29 @@ export const TypewriterMarkdown = React.memo<TypewriterMarkdownProps>(function T
isComplete,
className,
preserveLineBreaks,
streamKey,
streamSource = "live",
}) {
// Simply join all deltas - no artificial delays or character-by-character rendering
const content = deltas.join("");
const fullContent = deltas.join("");
const isStreaming = !isComplete && fullContent.length > 0;

// Show cursor only when streaming (not complete)
const isStreaming = !isComplete && content.length > 0;
// Two-clock streaming: ingestion (fullContent) vs presentation (visibleText).
// The jitter buffer reveals text at a steady cadence instead of bursty token clumps.
// Replay and completed streams bypass smoothing entirely.
const { visibleText } = useSmoothStreamingText({
fullText: fullContent,
isStreaming,
bypassSmoothing: streamSource === "replay",
streamKey: streamKey ?? "",
});

const streamingContextValue = useMemo(() => ({ isStreaming }), [isStreaming]);

return (
<StreamingContext.Provider value={streamingContextValue}>
<div className={cn("markdown-content", className)}>
<MarkdownCore
content={content}
content={visibleText}
parseIncompleteMarkdown={isStreaming}
preserveLineBreaks={preserveLineBreaks}
/>
Expand Down
Loading
Loading