diff --git a/apps/web/src/components/ChatMarkdown.logic.test.ts b/apps/web/src/components/ChatMarkdown.logic.test.ts
new file mode 100644
index 00000000000..d4da8e8a44b
--- /dev/null
+++ b/apps/web/src/components/ChatMarkdown.logic.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ STREAMING_MARKDOWN_RENDER_INTERVAL_MS,
+ streamingMarkdownRenderDelay,
+} from "./ChatMarkdown.logic";
+
+describe("streamingMarkdownRenderDelay", () => {
+ it("coalesces updates inside the render interval", () => {
+ expect(streamingMarkdownRenderDelay({ lastRenderedAt: 1_000, now: 1_020 })).toBe(30);
+ });
+
+ it("allows an immediate refresh once the interval elapsed", () => {
+ expect(
+ streamingMarkdownRenderDelay({
+ lastRenderedAt: 1_000,
+ now: 1_000 + STREAMING_MARKDOWN_RENDER_INTERVAL_MS,
+ }),
+ ).toBe(0);
+ expect(streamingMarkdownRenderDelay({ lastRenderedAt: 1_000, now: 2_000 })).toBe(0);
+ });
+
+ it("handles a clock moving backwards without exceeding the interval", () => {
+ expect(streamingMarkdownRenderDelay({ lastRenderedAt: 1_000, now: 900 })).toBe(
+ STREAMING_MARKDOWN_RENDER_INTERVAL_MS,
+ );
+ });
+});
diff --git a/apps/web/src/components/ChatMarkdown.logic.ts b/apps/web/src/components/ChatMarkdown.logic.ts
new file mode 100644
index 00000000000..a49195e32e2
--- /dev/null
+++ b/apps/web/src/components/ChatMarkdown.logic.ts
@@ -0,0 +1,9 @@
+export const STREAMING_MARKDOWN_RENDER_INTERVAL_MS = 50;
+
+export function streamingMarkdownRenderDelay(input: {
+ lastRenderedAt: number;
+ now: number;
+}): number {
+ const elapsed = Math.max(0, input.now - input.lastRenderedAt);
+ return Math.max(0, STREAMING_MARKDOWN_RENDER_INTERVAL_MS - elapsed);
+}
diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx
index 2c4a1fa7af6..1d590d90d11 100644
--- a/apps/web/src/components/ChatMarkdown.test.tsx
+++ b/apps/web/src/components/ChatMarkdown.test.tsx
@@ -8,6 +8,7 @@ import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
import { GitHubIcon } from "./Icons";
import { Button } from "./ui/button";
import { setMarkdownTaskChecked } from "./files/filePreviewMode";
+import { STREAMING_MARKDOWN_RENDER_INTERVAL_MS } from "./ChatMarkdown.logic";
vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
@@ -121,6 +122,7 @@ describe("ChatMarkdown streaming", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
let renderer: ReactTestRenderer | undefined;
try {
@@ -144,11 +146,15 @@ describe("ChatMarkdown streaming", () => {
,
);
});
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(STREAMING_MARKDOWN_RENDER_INTERVAL_MS);
+ });
expect(mounted.root.findAllByProps({ className: "chat-markdown-shiki" })).toHaveLength(1);
expect(mounted.root.findByProps({ "data-language": "text" })).toBe(codeBlock);
expect(codeBlock.props["data-wrap"]).toBe(String(!initialWrap));
} finally {
await act(async () => renderer?.unmount());
+ vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
}
@@ -222,6 +228,9 @@ describe("ChatMarkdown streaming", () => {
/>,
);
});
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(STREAMING_MARKDOWN_RENDER_INTERVAL_MS);
+ });
const copyUpdated = codeButton(mounted, "Copied");
await act(async () => {
copyUpdated.onClick?.({} as Parameters>[0]);
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx
index d72001eb9dd..7edfe5fe6a7 100644
--- a/apps/web/src/components/ChatMarkdown.tsx
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -181,6 +181,7 @@ import {
} from "../browser/openFileInPreview";
import { resolveLinkTarget } from "../browser/browserLinkTarget";
import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview";
+import { streamingMarkdownRenderDelay } from "./ChatMarkdown.logic";
interface ChatMarkdownProps {
text: string;
@@ -324,6 +325,33 @@ const highlightedCodeCache = new LRUCache(
MAX_HIGHLIGHT_CACHE_MEMORY_BYTES,
);
+function useStreamingMarkdownText(text: string, isStreaming: boolean): string {
+ const [renderedText, setRenderedText] = useState(text);
+ const lastRenderedAtRef = useRef(Date.now());
+
+ useEffect(() => {
+ if (!isStreaming) {
+ lastRenderedAtRef.current = Date.now();
+ setRenderedText((current) => (current === text ? current : text));
+ return;
+ }
+
+ const delay = streamingMarkdownRenderDelay({
+ lastRenderedAt: lastRenderedAtRef.current,
+ now: Date.now(),
+ });
+ const timeout = setTimeout(() => {
+ lastRenderedAtRef.current = Date.now();
+ setRenderedText(text);
+ }, delay);
+ return () => {
+ clearTimeout(timeout);
+ };
+ }, [isStreaming, text]);
+
+ return isStreaming ? renderedText : text;
+}
+
function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null {
const firstLineEnd = markdown.indexOf("\n", listItemStart);
const firstLine = markdown.slice(
@@ -2168,6 +2196,7 @@ function useChatMarkdownState({
imageBaseDir,
onImageExpand,
}: ChatMarkdownProps) {
+ const renderedText = useStreamingMarkdownText(text, isStreaming);
const { resolvedTheme } = useTheme();
const [localMediaPreview, setLocalMediaPreview] = useState(null);
const expandMedia = onImageExpand ?? setLocalMediaPreview;
@@ -2588,7 +2617,7 @@ function useChatMarkdownState({
resolvedTheme,
serverConfig,
skills,
- text,
+ text: renderedText,
threadRef,
updateThreadPullRequestLink,
}),
@@ -2614,7 +2643,7 @@ function useChatMarkdownState({
resolvedTheme,
serverConfig,
skills,
- text,
+ renderedText,
threadRef,
updateThreadPullRequestLink,
],
@@ -2624,6 +2653,7 @@ function useChatMarkdownState({
handleCopy,
markdownUrlTransform,
localMediaPreview,
+ renderedText,
setLocalMediaPreview,
};
}
@@ -3110,6 +3140,7 @@ function ChatMarkdown({
handleCopy,
markdownUrlTransform,
localMediaPreview,
+ renderedText,
setLocalMediaPreview,
} = useChatMarkdownState({ text, ...props });
const remarkPlugins = useMemo(
@@ -3139,7 +3170,7 @@ function ChatMarkdown({
components={CHAT_MARKDOWN_COMPONENTS}
urlTransform={markdownUrlTransform}
>
- {text}
+ {renderedText}
{localMediaPreview ? (