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 ? (