diff --git a/frontend/src/components/workspace/messages/markdown-content.tsx b/frontend/src/components/workspace/messages/markdown-content.tsx index 1f3d81480..6d6cbff13 100644 --- a/frontend/src/components/workspace/messages/markdown-content.tsx +++ b/frontend/src/components/workspace/messages/markdown-content.tsx @@ -16,8 +16,9 @@ import { import { type ClipboardSafeStreamdownProps } from "@/components/ai-elements/streamdown"; import { preprocessStreamdownMarkdown, + rehypeStreamingListItems, streamdownPluginsWithoutRawHtml, - streamdownWordAnimation, + streamdownSmoothStreamingAnimation, } from "@/core/streamdown"; import { SafeMessageResponse, @@ -241,8 +242,13 @@ export function MarkdownContent({ const effectiveRehypePlugins = useMemo(() => { const base = streamdownPluginsWithoutRawHtml.rehypePlugins ?? []; const extra = rehypePlugins ?? []; - return [...base, ...extra] as ClipboardSafeStreamdownProps["rehypePlugins"]; - }, [rehypePlugins]); + const streaming = isStreamingRender ? [rehypeStreamingListItems] : []; + return [ + ...base, + ...extra, + ...streaming, + ] as ClipboardSafeStreamdownProps["rehypePlugins"]; + }, [isStreamingRender, rehypePlugins]); const components = useMemo(() => { const baseComponents = { a: createMarkdownLinkComponent(), @@ -267,7 +273,7 @@ export function MarkdownContent({ rehypePlugins={effectiveRehypePlugins} components={toStreamdownComponents(components)} parseIncompleteMarkdown={isLoading} - animated={streamdownWordAnimation} + animated={streamdownSmoothStreamingAnimation} isAnimating={isLoading} > {normalizedContent} diff --git a/frontend/src/core/streamdown/plugins.ts b/frontend/src/core/streamdown/plugins.ts index b0784cb69..7374fc2a7 100644 --- a/frontend/src/core/streamdown/plugins.ts +++ b/frontend/src/core/streamdown/plugins.ts @@ -1,10 +1,12 @@ import { code } from "@streamdown/code"; import { mermaid } from "@streamdown/mermaid"; +import type { Root } from "hast"; import rehypeKatex from "rehype-katex"; import rehypeRaw from "rehype-raw"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import type { StreamdownProps } from "streamdown"; +import { visit } from "unist-util-visit"; const katexOptions = { output: "html", @@ -37,6 +39,52 @@ export const streamdownWordAnimation = { sep: "word", } as const satisfies Exclude; +export const streamdownSmoothStreamingAnimation = { + ...streamdownWordAnimation, + // Streamdown defaults to 40ms per new word. A large chunk can therefore + // delay later list-item text by seconds while its native marker is already + // visible. Smooth content reveal owns the pacing here, so start every new + // word's fade together with its surrounding marker. + stagger: 0, +} as const satisfies Exclude; + +/** + * Keeps native list markers in step with Streamdown's word reveal. + * + * A trailing `2.` or `-` is parsed as an empty list item while content is + * streaming. Hide that transient item, then mark it for a matching marker + * animation as soon as its first child arrives. Keep mid-list empty items in + * the box tree so ordered-list counters never renumber later items. + */ +export function rehypeStreamingListItems() { + return (tree: Root) => { + visit(tree, "element", (node, index, parent) => { + if (node.tagName !== "li") { + return; + } + + if (node.children.length === 0) { + const isTrailingListItem = + index !== undefined && + parent?.type === "element" && + (parent.tagName === "ol" || parent.tagName === "ul") && + !parent.children + .slice(index + 1) + .some( + (sibling) => + sibling.type === "element" && sibling.tagName === "li", + ); + if (isTrailingListItem) { + node.properties.hidden = true; + } + return; + } + + node.properties["data-streaming-list-item"] = "true"; + }); + }; +} + export const streamdownPluginsWithoutRawHtml = { plugins: streamdownPlugins.plugins, remarkPlugins: streamdownPlugins.remarkPlugins, diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index cecc2ab7e..577f1d6c3 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -72,6 +72,16 @@ @custom-variant dark (&:is(.dark *)); +@keyframes streaming-list-marker-fade-in { + from { + color: transparent; + } +} + +[data-streaming-list-item]::marker { + animation: streaming-list-marker-fade-in 200ms ease both; +} + @theme { --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", diff --git a/frontend/tests/unit/components/workspace/messages/markdown-content.dom.test.tsx b/frontend/tests/unit/components/workspace/messages/markdown-content.dom.test.tsx new file mode 100644 index 000000000..37396d4f0 --- /dev/null +++ b/frontend/tests/unit/components/workspace/messages/markdown-content.dom.test.tsx @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it } from "@rstest/core"; +import { cleanup, render, waitFor } from "@testing-library/react"; + +import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; + +afterEach(cleanup); + +describe("MarkdownContent streaming list transitions (DOM)", () => { + it("reveals the same list item with marker animation when content arrives", async () => { + const { container, rerender } = render( + , + ); + const initialItems = container.querySelectorAll( + '[data-streamdown="list-item"]', + ); + const firstItem = initialItems[0]!; + const pendingItem = initialItems[1]!; + + expect(pendingItem.hidden).toBe(true); + expect(pendingItem.hasAttribute("data-streaming-list-item")).toBe(false); + + rerender( + , + ); + + await waitFor(() => { + expect(pendingItem.textContent).toContain("Second"); + }); + const revealedItems = container.querySelectorAll( + '[data-streamdown="list-item"]', + ); + + expect(revealedItems[0]).toBe(firstItem); + expect(revealedItems[1]).toBe(pendingItem); + expect(pendingItem.hidden).toBe(false); + expect(pendingItem.getAttribute("data-streaming-list-item")).toBe("true"); + }); +}); diff --git a/frontend/tests/unit/components/workspace/messages/markdown-content.test.ts b/frontend/tests/unit/components/workspace/messages/markdown-content.test.ts index a0dd79429..8d3642897 100644 --- a/frontend/tests/unit/components/workspace/messages/markdown-content.test.ts +++ b/frontend/tests/unit/components/workspace/messages/markdown-content.test.ts @@ -98,6 +98,7 @@ describe("MarkdownContent streaming animation", () => { expect(html).toContain("data-sd-animate"); expect(html).toContain("--sd-animation:sd-fadeIn"); expect(html).toContain("--sd-duration:200ms"); + expect(html).not.toContain("--sd-delay"); expect(html).not.toContain("animate-fade-in"); }); @@ -108,6 +109,53 @@ describe("MarkdownContent streaming animation", () => { }); }); +describe("MarkdownContent streaming lists", () => { + it("hides a trailing empty ordered-list item until its content arrives", () => { + const html = renderMarkdown(["1. First", "", "2."].join("\n"), true); + + expect(html).toContain('data-streaming-list-item="true"'); + expect(html).toContain('data-streamdown="list-item" hidden=""'); + }); + + it("hides a trailing empty unordered-list item until its content arrives", () => { + const html = renderMarkdown("-", true); + + expect(html).toContain('data-streamdown="unordered-list"'); + expect(html).toContain('data-streamdown="list-item" hidden=""'); + expect(html).not.toContain('data-streaming-list-item="true"'); + }); + + it("keeps a mid-list empty item visible so ordered counters stay stable", () => { + const html = renderMarkdown( + ["1. First", "2.", "3. Third"].join("\n"), + true, + ); + const listItems = html.match(/]*>/g); + + expect(listItems).toHaveLength(3); + expect(listItems?.[1]).not.toContain("hidden"); + expect(html).not.toContain('hidden=""'); + }); + + it("marks nested list items without hiding existing content", () => { + const html = renderMarkdown( + ["1. Parent", " - Nested child"].join("\n"), + true, + ); + + expect(html.match(/data-streaming-list-item="true"/g)).toHaveLength(2); + expect(html).not.toContain('hidden=""'); + }); + + it("leaves completed list markup outside streaming treatment", () => { + const html = renderMarkdown(["1. First", "", "2."].join("\n"), false); + + expect(html).toContain('data-streamdown="ordered-list"'); + expect(html).not.toContain("data-streaming-list-item"); + expect(html).not.toContain('hidden=""'); + }); +}); + describe("MarkdownContent strikethrough", () => { it("preserves single tildes in temperature ranges", () => { const html = renderMarkdown("周六23~30℃;周日22~30℃", false); diff --git a/frontend/tests/unit/core/streamdown/plugins.test.ts b/frontend/tests/unit/core/streamdown/plugins.test.ts index 804866e8f..3716b57ab 100644 --- a/frontend/tests/unit/core/streamdown/plugins.test.ts +++ b/frontend/tests/unit/core/streamdown/plugins.test.ts @@ -8,6 +8,7 @@ import { reasoningPlugins, streamdownPlugins, streamdownRenderingPlugins, + streamdownSmoothStreamingAnimation, streamdownWordAnimation, } from "@/core/streamdown/plugins"; @@ -25,6 +26,15 @@ test("streaming word animation uses Streamdown's stable incremental animation", }); }); +test("smooth message streaming does not add a cumulative word delay", () => { + expect(streamdownSmoothStreamingAnimation).toEqual({ + animation: "fadeIn", + duration: 200, + sep: "word", + stagger: 0, + }); +}); + test("streamdownPlugins includes rehypeRaw", () => { expect(streamdownPlugins.rehypePlugins).toContain(rehypeRaw); });