Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 72 additions & 4 deletions apps/web/src/components/ChatMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,22 @@ import { Button } from "./ui/button";
import { setMarkdownTaskChecked } from "./files/filePreviewMode";

vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => null }));
vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
vi.mock("./chat/MermaidDiagram", () => ({
// Real Mermaid needs layout APIs jsdom lacks; a rendered diagram is an SVG.
MermaidDiagram: () => <svg aria-label="Diagram" />,
const { renderDiagram } = vi.hoisted(() => ({
renderDiagram: vi.fn<(source: string) => Promise<string> | undefined>(),
}));
vi.mock("./chat/MermaidDiagram", async () => {
const { use } = await import("react");
return {
// Real Mermaid needs layout APIs jsdom lacks; a rendered diagram is an SVG.
// Tests that return a promise from renderDiagram control when it appears.
MermaidDiagram({ source }: { source: string }) {
const pending = renderDiagram(source);
return <svg role="img" aria-label={pending ? use(pending) : "Diagram"} />;
},
};
});

vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
vi.mock("../hooks/useSettings", async (importOriginal) => {
const actual = await importOriginal<typeof import("../hooks/useSettings")>();
Expand Down Expand Up @@ -171,6 +182,63 @@ describe("ChatMarkdown bare anchor placeholders", () => {
});
});

describe("ChatMarkdown Mermaid streaming", () => {
it.each([
["```mermaid\nflowchart LR\nA --> B", "\n```"],
["~~~~mermaid\nflowchart LR\nA --> B\n~~~", "\n~~~~"],
["> ```mermaid\n> flowchart LR\n> A --> B", "\n> ```"],
["- Diagram\n\n ```mermaid\n flowchart LR\n A --> B", "\n ```"],
["```mermaid\nflowchart LR\nA --> B\n ```", "\n```"],
["> ```mermaid\n> flowchart LR\n> A --> B\n> ```", "\n> ```"],
["- Diagram\n\n ```mermaid\n flowchart LR\n A --> B\n ```", "\n ```"],
["```mermaid\r\nflowchart LR\r\nA --> B\r\n\t```", "\r\n```"],
["```mermaid\r\nflowchart LR\r\nA --> B\r\n\t```\r", "\n```"],
])("reveals a finished fence before the message finishes: %s", async (open, closing) => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
let renderer: ReactTestRenderer | undefined;
let finishRender!: (label: string) => void;
const pending = new Promise<string>((resolve) => {
finishRender = resolve;
});
renderDiagram.mockReset().mockReturnValue(pending);
const message = (text: string, isStreaming = true) => (
<ChatMarkdown cwd={undefined} text={text} isStreaming={isStreaming} />
);
const closed = `${open}${closing}\n\n`;
try {
await act(async () => {
renderer = create(message(open));
});
expect(renderDiagram).not.toHaveBeenCalled();
await act(async () => {
renderer!.update(message(closed));
});
expect(renderDiagram).toHaveBeenCalled();
expect(renderer!.root.findAllByProps({ role: "img" })).toHaveLength(0);
expect(renderer!.root.findAllByProps({ className: "chat-markdown-shiki" })).toHaveLength(0);
expect(renderer!.root.findByType("pre").props["aria-hidden"]).toBe(true);

await act(async () => {
finishRender("Completed diagram");
});
const diagram = renderer!.root.findByProps({ role: "img" });
for (const streaming of [true, false]) {
await act(async () => {
renderer!.update(message(`${closed}The remaining explanation.`, streaming));
});
expect(renderer!.root.findByProps({ role: "img" })).toBe(diagram);
expect(
renderer!.root.findAll((node) => node.children.includes("The remaining explanation.")),
).not.toHaveLength(0);
}
} finally {
await act(async () => renderer?.unmount());
vi.unstubAllGlobals();
renderDiagram.mockReset();
}
});
});

describe("ChatMarkdown context references", () => {
it("renders text and image references through the chip renderer, with readable fallback", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
Expand Down
22 changes: 15 additions & 7 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,11 @@ function extractPreCodeMeta(node: unknown): string | undefined {
return typeof meta === "string" && meta.trim().length > 0 ? meta.trim() : undefined;
}

function isClosedCodeFence(node: ReactMarkdownExtraProps["node"], text: string): boolean {
function isClosedCodeFence(
node: ReactMarkdownExtraProps["node"],
text: string,
code: string,
): boolean {
const start = node?.position?.start.offset;
const end = node?.position?.end.offset;
if (start === undefined || end === undefined) return false;
Expand All @@ -473,7 +477,11 @@ function isClosedCodeFence(node: ReactMarkdownExtraProps["node"], text: string):
opening !== undefined &&
closing !== undefined &&
opening[0] === closing[0] &&
closing.length >= opening.length
closing.length >= opening.length &&
// A real closer is excluded from the parsed code. Invalid, overindented
// markers remain in it, so the source must have an extra fence line. A
// stream cut inside a CRLF leaves a lone CR that is not a line of its own.
source.replace(/\r$/, "").split(/\r\n|\r|\n/).length > code.split(/\r\n|\r|\n/).length
);
}

Expand Down Expand Up @@ -1031,9 +1039,9 @@ function MarkdownMermaidCodeBlock({
<RenderErrorBoundary resetKeys={[code, theme]} fallback={children}>
<Suspense
fallback={
<div className="flex min-h-36 items-center justify-center text-xs text-muted-foreground">
Rendering diagram
</div>
<pre className="invisible" aria-hidden>
{code}
</pre>
}
>
<MermaidDiagram source={code} theme={theme} onExpand={onExpand} />
Expand Down Expand Up @@ -3286,7 +3294,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
code={codeBlock.code}
fenceTitle={fenceTitle}
theme={resolvedTheme}
isStreaming={isStreaming}
isStreaming={isStreaming && !isClosedCodeFence(node, text, codeBlock.code)}
onExpand={(src) => expandMedia({ images: [{ src, name: "Mermaid diagram" }], index: 0 })}
>
{highlightedCode}
Expand All @@ -3300,7 +3308,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
fenceTitle={fenceTitle}
theme={resolvedTheme}
onRunShellCommand={
onRunShellCommand && !isStreaming && isClosedCodeFence(node, text)
onRunShellCommand && !isStreaming && isClosedCodeFence(node, text, codeBlock.code)
? onRunShellCommand
: undefined
}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/MermaidDiagram.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ export function MermaidDiagram({
}

return (
<div className="overflow-x-auto">
<div className="chat-markdown-mermaid overflow-x-auto">
<button
type="button"
aria-label="Expand diagram"
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -2019,7 +2019,8 @@ code {
shot, no layout change. */
@media (prefers-reduced-motion: no-preference) {
.chat-markdown[data-streaming] > *,
.chat-markdown[data-streaming] .chat-markdown-shiki {
.chat-markdown[data-streaming] .chat-markdown-shiki,
.chat-markdown[data-streaming] .chat-markdown-mermaid {
transition: opacity 600ms ease-out;

@starting-style {
Expand Down
Loading