1// @vitest-environment jsdom
2
3import { EnvironmentId, type AuthEnvironmentScope } from "@t3tools/contracts";
4import { createRoot } from "react-dom/client";
5import { useThreadFindHighlights } from "./chat/threadFindHighlights";
6import { searchableMessageSegments } from "@t3tools/shared/threadFindText";
7import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch";
8
9import { MarkdownFindContext } from "./chat/markdownFindContext";
10import { act, type ComponentProps, type ReactNode } from "react";
11import { renderToStaticMarkup } from "react-dom/server";
12import { create, type ReactTestRenderer } from "react-test-renderer";
13import { describe, expect, it, vi } from "vite-plus/test";
14
15import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
16import { GitHubIcon } from "./Icons";
17import { Button } from "./ui/button";
18
19vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => null }));
20vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
21vi.mock("../state/query", () => ({
22 useEnvironmentQuery: () => ({ data: null, error: null }),
23}));
24vi.mock("./chat/MermaidDiagram", () => ({
25 // Real Mermaid needs layout APIs jsdom lacks; a rendered diagram is an SVG.
26 MermaidDiagram: () => <svg aria-label="Diagram" />,
27}));
28vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
29vi.mock("../hooks/useSettings", async (importOriginal) => {
30 const actual = await importOriginal<typeof import("../hooks/useSettings")>();
31 const settings = actual.getClientSettings();
32 return {
33 ...actual,
34 useClientSettings: (select?: (value: typeof settings) => unknown) =>
35 select ? select(settings) : settings,
36 };
37});
38vi.mock("./ui/tooltip", async () => {
39 const { cloneElement, isValidElement } = await import("react");
40 return {
41 Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
42 TooltipTrigger({
43 render,
44 children,
45 }: ComponentProps<typeof import("./ui/tooltip").TooltipTrigger>) {
46 if (!isValidElement(render)) return <>{children}</>;
47 return children === undefined ? render : cloneElement(render, undefined, children);
48 },
49 TooltipPopup: () => null,
50 };
51});
52vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() }));
53vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
54vi.mock("../state/session", async (importOriginal) => {
55 const actual = await importOriginal<typeof import("../state/session")>();
56 const { AuthStandardClientScopes } = await import("@t3tools/contracts");
57 const grantedScopes = new Set<AuthEnvironmentScope>(AuthStandardClientScopes);
58 const hasScope = (environmentId: EnvironmentId | null, scope: AuthEnvironmentScope) =>
59 environmentId !== null && grantedScopes.has(scope);
60 return {
61 ...actual,
62 useEnvironmentScope: hasScope,
63 readEnvironmentScope: hasScope,
64 usePreparedConnection: () => ({ _tag: "Loading" }),
65 };
66});
67vi.mock("../state/entities", () => ({
68 readThreadShell: () => null,
69 useProjects: () => [],
70 useServerConfigs: () => new Map(),
71}));
72vi.mock("../remoteOpen", () => ({
73 useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }),
74}));
75vi.mock("../editorPreferences", () => ({
76 useOpenInPreferredEditor: () => vi.fn(),
77 usePreferredEditor: () => [null, vi.fn()],
78}));
79vi.mock("~/lib/openPullRequestLink", () => ({
80 findProjectOnChangeRequestHost: () => undefined,
81 parseChangeRequestUrl: () => null,
82 resolvePullRequestPreviewTarget: () => null,
83 useOpenChangeRequestLink: () => vi.fn(),
84}));
85
86import ChatMarkdown, {
87 canUseMarkdownFileShellActions,
88 hasMarkdownFilePrimaryAction,
89 shouldUseMarkdownFileBrowserPrimaryAction,
90} from "./ChatMarkdown";
91
92function codeButton(renderer: ReactTestRenderer, label: string) {
93 const button = renderer.root
94 .findAllByType(Button)
95 .find((instance) => instance.props["aria-label"] === label);
96 if (!button) throw new Error(`Missing code button: ${label}`);
97 return button.props as ComponentProps<typeof Button>;
98}
99
100describe("ChatMarkdown inline rendering", () => {
101 it.each([true, false])(
102 "keeps authored file-link labels as one actionable chip with parseRawHtml=%s",
103 async (parseRawHtml) => {
104 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
105 const container = document.createElement("div");
106 document.body.append(container);
107 const root = createRoot(container);
108 const text =
109 "[relative](./foo.md), [**formatted** `source`](./src/main.ts), [ref][file] and [foo.md](./foo.md)\n\n[file]: ./bar.md";
110 try {
111 await act(() =>
112 root.render(
113 <ChatMarkdown
114 cwd="/workspace/project"
115 environmentId={EnvironmentId.make("env-links")}
116 parseRawHtml={parseRawHtml}
117 text={text}
118 />,
119 ),
120 );
121 const chips = [
122 ...container.querySelectorAll<HTMLAnchorElement>("a.chat-markdown-file-link"),
123 ];
124 expect(chips.map((chip) => chip.textContent)).toEqual([
125 "relative",
126 "formatted source",
127 "ref",
128 "foo.md",
129 ]);
130 expect(container.querySelector("p")!.textContent).toBe(
131 "relative, formatted source, ref and foo.md",
132 );
133 expect(chips.map((chip) => chip.getAttribute("href"))).toEqual([
134 "/workspace/project/./foo.md",
135 "/workspace/project/./src/main.ts",
136 "/workspace/project/./bar.md",
137 "/workspace/project/./foo.md",
138 ]);
139 expect(chips[1]!.querySelector("strong")!.textContent).toBe("formatted");
140 expect(chips[1]!.querySelector("code")!.textContent).toBe("source");
141 chips[0]!.focus();
142 expect(document.activeElement).toBe(chips[0]);
143 expect(chips[0]!.getAttribute("aria-description")).toBe("/workspace/project/./foo.md");
144 } finally {
145 await act(() => root.unmount());
146 container.remove();
147 vi.unstubAllGlobals();
148 }
149 },
150 );
151});
152
153describe("ChatMarkdown consecutive videos", () => {
154 it.each([
155 [
156 "separate paragraphs",
157 "![First](https://media.test/first.mp4)\n\n![Second](https://media.test/second.webm)",
158 ],
159 [
160 "one paragraph",
161 "![First](https://media.test/first.mp4) ![Second](https://media.test/second.webm)",
162 ],
163 [
164 "hard breaks",
165 "![First](https://media.test/first.mp4) \n![Second](https://media.test/second.webm)",
166 ],
167 [
168 "reference images",
169 "![First][first]\n\n![Second][second]\n\n[first]: https://media.test/first.mp4\n[second]: https://media.test/second.webm",
170 ],
171 [
172 "sanitized HTML images",
173 '<img src="https://media.test/first.mp4" alt="First" width="640" height="480">\n\n<img src="https://media.test/second.webm" alt="Second">',
174 ],
175 ])("groups %s without changing playback sources or accessible names", async (_case, text) => {
176 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
177 const pause = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
178 const container = document.createElement("div");
179 const root = createRoot(container);
180 try {
181 await act(() => root.render(<ChatMarkdown cwd={undefined} text={text} />));
182 const gallery = container.querySelector(".chat-markdown-video-row");
183 expect(gallery).not.toBeNull();
184 const videos = [...gallery!.querySelectorAll("video")];
185 expect(videos.map((video) => video.src)).toEqual([
186 "https://media.test/first.mp4",
187 "https://media.test/second.webm",
188 ]);
189 expect(videos.map((video) => video.getAttribute("aria-label"))).toEqual(["First", "Second"]);
190 expect(videos.every((video) => !video.controls && video.playsInline)).toBe(true);
191 expect(gallery!.querySelectorAll('button[aria-label="Play video"]')).toHaveLength(2);
192 if (_case === "sanitized HTML images") {
193 expect(videos[0]!.style.aspectRatio).toBe("640 / 480");
194 }
195 } finally {
196 await act(() => root.unmount());
197 pause.mockRestore();
198 vi.unstubAllGlobals();
199 }
200 });
201
202 it("keeps prose, links, and images separate from grouped and lone videos", async () => {
203 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
204 const pause = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
205 const container = document.createElement("div");
206 const root = createRoot(container);
207 const text = [
208 "![First](https://media.test/first.mp4)",
209 "![Second](https://media.test/second.webm)",
210 "Between videos.",
211 "![Third](https://media.test/third.mp4)",
212 "[Source](https://media.test/source)",
213 "![Still](https://media.test/still.png)",
214 "Caption ![Inline](https://media.test/inline.mp4)",
215 "![Last](https://media.test/last.mp4)",
216 ].join("\n\n");
217 try {
218 await act(() => root.render(<ChatMarkdown cwd={undefined} text={text} />));
219 expect(
220 [...container.querySelectorAll(".chat-markdown-video-row")].map((row) =>
221 row.getAttribute("data-video-count"),
222 ),
223 ).toEqual(["2", "1", "1"]);
224 expect(
225 container.querySelector(".chat-markdown-video-row")!.querySelectorAll("video"),
226 ).toHaveLength(2);
227 const paragraphs = [...container.querySelectorAll("p")];
228 expect(paragraphs.map((paragraph) => paragraph.textContent)).toContain("Between videos.");
229 expect(container.querySelector('video[aria-label="Third"]')?.getAttribute("src")).toBe(
230 "https://media.test/third.mp4",
231 );
232 expect(container.querySelector('video[aria-label="Last"]')?.getAttribute("src")).toBe(
233 "https://media.test/last.mp4",
234 );
235 expect(
236 container
237 .querySelector('a[href="https://media.test/source"]')!
238 .closest(".chat-markdown-video-row"),
239 ).toBeNull();
240 expect(
241 container.querySelector('img[alt="Still"]')!.closest(".chat-markdown-video-row"),
242 ).toBeNull();
243 const caption = paragraphs.find((paragraph) =>
244 paragraph.querySelector('[aria-label="Inline"]'),
245 )!;
246 const prose = [...caption.childNodes]
247 .filter((node) => node.nodeType === Node.TEXT_NODE)
248 .map((node) => node.textContent)
249 .join("");
250 expect(prose).toBe("Caption ");
251 } finally {
252 await act(() => root.unmount());
253 pause.mockRestore();
254 vi.unstubAllGlobals();
255 }
256 });
257
258 it("keeps literal HTML mode and raw-video sanitization unchanged", async () => {
259 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
260 const pause = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
261 const container = document.createElement("div");
262 const root = createRoot(container);
263 try {
264 await act(() =>
265 root.render(
266 <ChatMarkdown
267 cwd={undefined}
268 parseRawHtml={false}
269 text={
270 '![First](https://media.test/first.mp4)\n\n![Second](https://media.test/second.webm)\n\n<video src="https://media.test/raw.mp4" controls></video>'
271 }
272 />,
273 ),
274 );
275 expect(container.querySelectorAll("video")).toHaveLength(2);
276 expect(container.querySelectorAll(".chat-markdown-video-row")).toHaveLength(1);
277 expect(container.textContent).toContain(
278 '<video src="https://media.test/raw.mp4" controls></video>',
279 );
280 await act(() =>
281 root.render(
282 <ChatMarkdown
283 cwd={undefined}
284 text='<video src="https://media.test/raw.mp4" controls></video>'
285 />,
286 ),
287 );
288 expect(container.querySelector("video")).toBeNull();
289 } finally {
290 await act(() => root.unmount());
291 pause.mockRestore();
292 vi.unstubAllGlobals();
293 }
294 });
295
296 it("preserves authored paragraph anchors beside grouped videos", async () => {
297 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
298 const pause = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
299 const container = document.createElement("div");
300 const root = createRoot(container);
301 try {
302 await act(() =>
303 root.render(
304 <ChatMarkdown
305 cwd={undefined}
306 text={
307 '<p id="clip"><img src="https://media.test/anchored.mp4" alt="Anchored"></p>\n\n![First](https://media.test/first.mp4)\n\n![Second](https://media.test/second.webm)'
308 }
309 />,
310 ),
311 );
312 const anchored = container.querySelector("#user-content-clip");
313 expect(anchored?.tagName).toBe("P");
314 expect(anchored?.querySelector("video")?.src).toBe("https://media.test/anchored.mp4");
315 expect(anchored?.closest(".chat-markdown-video-row")).toBeNull();
316 expect(
317 container.querySelector(".chat-markdown-video-row")?.querySelectorAll("video"),
318 ).toHaveLength(2);
319 } finally {
320 await act(() => root.unmount());
321 pause.mockRestore();
322 vi.unstubAllGlobals();
323 }
324 });
325
326 it.each(["", "```text\ncompleted prefix\n```\n\n"])(
327 "preserves playing video nodes as a streamed singleton grows (prefix=%j)",
328 async (completedPrefix) => {
329 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
330 const pause = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
331 const container = document.createElement("div");
332 const root = createRoot(container);
333 const prefix = `${completedPrefix}![First](https://media.test/first.mp4)`;
334 try {
335 await act(() => root.render(<ChatMarkdown cwd={undefined} isStreaming text={prefix} />));
336 const firstPlayer = container.querySelector("video");
337 expect(firstPlayer).not.toBeNull();
338 firstPlayer!.currentTime = 7;
339 await act(() => firstPlayer!.dispatchEvent(new Event("play")));
340 await act(() =>
341 root.render(
342 <ChatMarkdown
343 cwd={undefined}
344 isStreaming
345 text={`${prefix}\n\n![Second](https://media.test/second.webm)`}
346 />,
347 ),
348 );
349 expect(container.querySelector("video")).toBe(firstPlayer);
350 expect(container.querySelector("video")?.currentTime).toBe(7);
351 expect(
352 container.querySelector(".chat-markdown-video-row")?.getAttribute("data-video-count"),
353 ).toBe("2");
354 await act(() =>
355 root.render(
356 <ChatMarkdown
357 cwd={undefined}
358 isStreaming
359 text={`${prefix}\n\n![Second](https://media.test/second.webm)\n\n![Third](https://media.test/third.mp4)`}
360 />,
361 ),
362 );
363 expect(
364 container.querySelector(".chat-markdown-video-row")?.getAttribute("data-video-count"),
365 ).toBe("3");
366 expect(
367 [...container.querySelectorAll("video")].map((video) => video.getAttribute("aria-label")),
368 ).toEqual(["First", "Second", "Third"]);
369 expect(container.querySelector("video")).toBe(firstPlayer);
370 expect(container.querySelector("video")?.currentTime).toBe(7);
371 } finally {
372 await act(() => root.unmount());
373 pause.mockRestore();
374 vi.unstubAllGlobals();
375 }
376 },
377 );
378});
379
380describe("ChatMarkdown bare anchor placeholders", () => {
381 it.each(["<A>", "<a>", "<a >", "<a/>", "<A/>", "<a />"])(
382 "preserves unmatched %s without linking later blocks",
383 (token) => {
384 const text = `- **"From ${token}"** appears in the header.\n\n- **Tests:** cover inheritance.\n\nThe deferred move continues on B.\n\nSee <a href="https://example.com">the link</a>.`;
385 const document = new DOMParser().parseFromString(
386 renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text={text} />),
387 "text/html",
388 );
389
390 expect(document.querySelector("strong")?.textContent).toBe(`"From ${token}"`);
391 expect([...document.querySelectorAll("a")].map((link) => link.textContent)).toEqual([
392 "the link",
393 ]);
394 expect(document.querySelectorAll("li")).toHaveLength(2);
395 expect(
396 [...document.querySelectorAll("p")].map((paragraph) => paragraph.textContent),
397 ).toContain("The deferred move continues on B.");
398 },
399 );
400
401 it.each(["</a> ", "<div>more</div>\n</a>"])(
402 "preserves a paired anchor closing in the raw block %s",
403 (closing) => {
404 const document = new DOMParser().parseFromString(
405 renderToStaticMarkup(
406 <ChatMarkdown cwd="/tmp/project" text={`See <a>label\n\n${closing}\n\nfinish`} />,
407 ),
408 "text/html",
409 );
410 expect(document.querySelector("p")?.textContent).toBe("See label");
411 },
412 );
413
414 it("preserves a paired anchor after comment-looking raw text", () => {
415 const document = new DOMParser().parseFromString(
416 renderToStaticMarkup(
417 <ChatMarkdown cwd="/tmp/project" text="See <a>label<script><!-- </script> --></a>" />,
418 ),
419 "text/html",
420 );
421 expect(document.querySelector("p")?.textContent).toBe("See label -->");
422 });
423
424 it.each(["<!-- </a> -->", '<div title="</a>">more</div>', '<script>"</a>"</script>'])(
425 "ignores apparent closing anchors inside %s",
426 (html) => {
427 const document = new DOMParser().parseFromString(
428 renderToStaticMarkup(
429 <ChatMarkdown cwd="/tmp/project" text={`Before <A>.\n\n${html}\n\nAfter.`} />,
430 ),
431 "text/html",
432 );
433 expect(document.querySelector("p")?.textContent).toBe("Before <A>.");
434 expect(document.querySelectorAll("a")).toHaveLength(0);
435 },
436 );
437
438 it("preserves paired HTML anchors, details, markdown links, and inline code", () => {
439 const text =
440 'Bare <a>label</a>, <a id="section"></a>, `<A>`, and [docs](https://example.com).\n\n<details><summary>More</summary>Details</details>';
441 const document = new DOMParser().parseFromString(
442 renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text={text} />),
443 "text/html",
444 );
445
446 expect([...document.querySelectorAll("a")].map((link) => link.textContent)).toEqual([
447 "label",
448 "",
449 "docs",
450 ]);
451 expect(document.querySelector("code")?.textContent).toBe("<A>");
452 expect(document.querySelector("[data-markdown-details]")?.textContent).toContain("More");
453 });
454});
455
456describe("ChatMarkdown context references", () => {
457 it("renders text and image references through the chip renderer, with readable fallback", async () => {
458 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
459 let renderer: ReactTestRenderer | undefined;
460 const text =
461 "See [Terminal output](t3-context://v1/terminal/term-1) and ![Error image](t3-context://v1/image/img-1).";
462 try {
463 await act(async () => {
464 renderer = create(
465 <ChatMarkdown
466 cwd={undefined}
467 text={text}
468 renderContextReference={({ kind, label }) => (
469 <button>
470 {kind}: {label}
471 </button>
472 )}
473 />,
474 );
475 });
476 expect(
477 renderer!.root.findAllByType("button").map((button) => button.children.join("")),
478 ).toEqual(["terminal: Terminal output", "image: Error image"]);
479 expect(renderer!.root.findAllByType("img")).toHaveLength(0);
480 expect(renderer!.root.findAllByType("a")).toHaveLength(0);
481 await act(async () => {
482 renderer!.update(<ChatMarkdown cwd={undefined} text={text} />);
483 });
484 expect(renderer!.root.findAllByType("span").map((span) => span.children.join(""))).toEqual([
485 "Terminal output",
486 "Error image",
487 ]);
488 expect(renderer!.root.findAllByType("img")).toHaveLength(0);
489 } finally {
490 await act(async () => {
491 renderer?.unmount();
492 });
493 vi.unstubAllGlobals();
494 }
495 });
496
497 it("reads formatted context labels through nested markup instead of the context id", async () => {
498 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
499 let renderer: ReactTestRenderer | undefined;
500 const seen: Array<string> = [];
501 try {
502 await act(async () => {
503 renderer = create(
504 <ChatMarkdown
505 cwd={undefined}
506 text="See [**Bold** `code`](t3-context://v1/terminal/term-1)."
507 renderContextReference={({ kind, label }) => {
508 seen.push(`${kind}: ${label}`);
509 return <button>{label}</button>;
510 }}
511 />,
512 );
513 });
514 expect(seen).toEqual(["terminal: Bold code"]);
515 } finally {
516 await act(async () => {
517 renderer?.unmount();
518 });
519 vi.unstubAllGlobals();
520 }
521 });
522});
523
524describe("ChatMarkdown favicon privacy", () => {
525 it("suppresses private link images while preserving public links across updates", async () => {
526 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
527 let renderer: ReactTestRenderer | undefined;
528 const markdown = (url: string) => <ChatMarkdown cwd="/tmp/project" text={`[Link](${url})`} />;
529 try {
530 await act(async () => {
531 renderer = create(markdown("https://example.com"));
532 });
533 expect(renderer!.root.findAllByType("img").map((image) => image.props.src)).toEqual([
534 "https://www.google.com/s2/favicons?domain=example.com&sz=32",
535 ]);
536 for (const url of ["http://192.168.1.10:8080", "http://localhost:3000", "http://home.arpa"]) {
537 await act(async () => {
538 renderer!.update(markdown(url));
539 });
540 expect(renderer!.root.findAllByType("img")).toHaveLength(0);
541 }
542 await act(async () => {
543 renderer!.update(markdown("https://example.com"));
544 });
545 expect(renderer!.root.findAllByType("img")).toHaveLength(1);
546 // GitHub links draw the brand mark in currentColor instead of fetching a favicon.
547 await act(async () => {
548 renderer!.update(markdown("https://github.com/pingdotgg/t3code/pull/1"));
549 });
550 expect(renderer!.root.findAllByType("img")).toHaveLength(0);
551 expect(renderer!.root.findAllByType(GitHubIcon)).toHaveLength(1);
552 } finally {
553 await act(async () => {
554 renderer?.unmount();
555 });
556 vi.unstubAllGlobals();
557 }
558 });
559});
560
561describe("ChatMarkdown streaming", () => {
562 it("runs only a complete single-line shell block after a click", async () => {
563 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
564 const onRunShellCommand = vi.fn();
565 let renderer: ReactTestRenderer | undefined;
566 const message = (text: string, isStreaming = false) => (
567 <ChatMarkdown
568 cwd="/tmp/project"
569 text={text}
570 isStreaming={isStreaming}
571 onRunShellCommand={onRunShellCommand}
572 />
573 );
574 try {
575 await act(async () => {
576 renderer = create(message("```bash\necho hello\n```", true));
577 });
578 const mounted = renderer!;
579 expect(
580 mounted.root
581 .findAllByType(Button)
582 .some((button) => button.props["aria-label"] === "Run in terminal"),
583 ).toBe(false);
584
585 await act(async () => {
586 mounted.update(message("```bash\necho hello\n```"));
587 });
588 await act(async () => {
589 codeButton(mounted, "Run in terminal").onClick?.({} as never);
590 });
591 expect(onRunShellCommand).toHaveBeenCalledExactlyOnceWith("echo hello");
592
593 for (const text of [
594 "~~~bash\necho tilde\n~~~",
595 "> ```bash\n> echo quote\n> ```",
596 "````bash\necho four\n````",
597 ]) {
598 await act(async () => {
599 mounted.update(message(text));
600 });
601 expect(codeButton(mounted, "Run in terminal")).toBeDefined();
602 }
603
604 for (const text of [
605 "```bash\necho one\necho two\n```",
606 "```typescript\necho hello\n```",
607 "```bash\n\n```",
608 "```bash\necho hello\n\n```",
609 "```bash\necho hello\\\n```",
610 "```bash\necho safe \u202e#\n```",
611 "```bash\necho incomplete",
612 "~~~bash\necho incomplete",
613 "````bash\necho incomplete\n```",
614 '<pre><code class="language-bash">echo html</code></pre>',
615 ]) {
616 await act(async () => {
617 mounted.update(message(text));
618 });
619 expect(
620 mounted.root
621 .findAllByType(Button)
622 .some((button) => button.props["aria-label"] === "Run in terminal"),
623 ).toBe(false);
624 }
625 } finally {
626 await act(async () => renderer?.unmount());
627 vi.unstubAllGlobals();
628 }
629 });
630
631 it("does not retokenize completed lines when streaming finishes", async () => {
632 const highlighter = await getSyntaxHighlighterPromise("typescript");
633 const highlight = vi.spyOn(highlighter, "codeToHast");
634 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
635 let renderer: ReactTestRenderer | undefined;
636 const text = "```typescript\nconst completed = 1;\nconst current = 2;";
637 try {
638 await act(async () => {
639 renderer = create(<ChatMarkdown cwd="/tmp/project" text={text} isStreaming />);
640 });
641 expect(highlight).toHaveBeenCalled();
642 highlight.mockClear();
643 await act(async () => {
644 renderer!.update(<ChatMarkdown cwd="/tmp/project" text={text + "\n```"} />);
645 });
646 expect(highlight.mock.calls.every(([code]) => !code.includes("const completed"))).toBe(true);
647 } finally {
648 await act(async () => renderer?.unmount());
649 vi.unstubAllGlobals();
650 vi.restoreAllMocks();
651 }
652 });
653
654 it("recovers highlighting after a failed fence changes without resetting its controls", async () => {
655 const highlighter = await getSyntaxHighlighterPromise("text");
656 const codeToHast = highlighter.codeToHast.bind(highlighter);
657 let fail = true;
658 vi.spyOn(highlighter, "codeToHast").mockImplementation((...args) => {
659 if (fail) throw new Error("Temporary highlighter failure");
660 return codeToHast(...args);
661 });
662 vi.spyOn(console, "error").mockImplementation(() => {});
663 vi.spyOn(console, "warn").mockImplementation(() => {});
664 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
665 let renderer: ReactTestRenderer | undefined;
666
667 try {
668 await act(async () => {
669 renderer = create(
670 <ChatMarkdown cwd="/tmp/project" text={"```text\ninitial\n```"} isStreaming />,
671 );
672 });
673 const mounted = renderer!;
674 const codeBlock = mounted.root.findByProps({ "data-language": "text" });
675 const initialWrap = codeBlock.props["data-wrap"] === "true";
676 const wrap = codeButton(mounted, initialWrap ? "Disable line wrap" : "Wrap lines");
677 await act(async () => {
678 wrap.onClick?.({} as Parameters<NonNullable<typeof wrap.onClick>>[0]);
679 });
680 expect(mounted.root.findAllByProps({ className: "chat-markdown-shiki" })).toHaveLength(0);
681
682 fail = false;
683 await act(async () => {
684 mounted.update(
685 <ChatMarkdown cwd="/tmp/project" text={"```text\nrecovered\n```"} isStreaming />,
686 );
687 });
688 expect(mounted.root.findAllByProps({ className: "chat-markdown-shiki" })).toHaveLength(1);
689 expect(mounted.root.findByProps({ "data-language": "text" })).toBe(codeBlock);
690 expect(codeBlock.props["data-wrap"]).toBe(String(!initialWrap));
691 } finally {
692 await act(async () => renderer?.unmount());
693 vi.unstubAllGlobals();
694 vi.restoreAllMocks();
695 }
696 });
697
698 it("preserves code controls and details without highlighting an unchanged fence again", async () => {
699 const highlighter = await getSyntaxHighlighterPromise("text");
700 const highlight = vi.spyOn(highlighter, "codeToHast");
701 const writeText = vi.fn(async (_text: string) => {});
702 vi.stubGlobal("navigator", { clipboard: { writeText } });
703 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
704 vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
705 let renderer: ReactTestRenderer | undefined;
706 const text = [
707 "```text",
708 "First code block",
709 "```",
710 "",
711 "<details><summary>More</summary>",
712 "",
713 "Details content",
714 "",
715 "</details>",
716 "",
717 "Streaming reply",
718 ].join("\n");
719
720 try {
721 await act(async () => {
722 renderer = create(<ChatMarkdown cwd="/tmp/project" text={text} isStreaming />);
723 });
724 const mounted = renderer!;
725 const codeBlock = mounted.root.findByProps({ "data-language": "text" });
726 const initialWrap = codeBlock.props["data-wrap"] === "true";
727 const wrap = codeButton(mounted, initialWrap ? "Disable line wrap" : "Wrap lines");
728 const copy = codeButton(mounted, "Copy code");
729 await act(async () => {
730 wrap.onClick?.({} as Parameters<NonNullable<typeof wrap.onClick>>[0]);
731 copy.onClick?.({} as Parameters<NonNullable<typeof copy.onClick>>[0]);
732 });
733
734 const detailsButton = mounted.root.find(
735 (instance) =>
736 instance.type === "button" && instance.props["data-markdown-details-summary"] === "",
737 );
738 await act(async () => {
739 detailsButton.props.onClick({ nativeEvent: new Event("click") });
740 });
741 const details = mounted.root.findByProps({ "data-markdown-details": "" });
742 expect(details.props["data-markdown-details-open"]).toBe("true");
743 expect(writeText).toHaveBeenCalledWith("First code block\n");
744 expect(highlight).toHaveBeenCalledTimes(1);
745
746 for (let index = 0; index < 10; index += 1) {
747 await act(async () => {
748 mounted.update(<ChatMarkdown cwd="/tmp/project" text={`${text} ${index}`} isStreaming />);
749 });
750 }
751
752 expect(highlight).toHaveBeenCalledTimes(1);
753 expect(mounted.root.findByProps({ "data-language": "text" })).toBe(codeBlock);
754 expect(codeBlock.props["data-wrap"]).toBe(String(!initialWrap));
755 expect(mounted.root.findByProps({ "data-markdown-details": "" })).toBe(details);
756 expect(details.props["data-markdown-details-open"]).toBe("true");
757 await act(async () => {
758 mounted.update(
759 <ChatMarkdown
760 cwd="/tmp/project"
761 text={text.replace("First code block", "Updated code block")}
762 isStreaming
763 />,
764 );
765 });
766 const copyUpdated = codeButton(mounted, "Copied");
767 await act(async () => {
768 copyUpdated.onClick?.({} as Parameters<NonNullable<typeof copyUpdated.onClick>>[0]);
769 });
770 expect(writeText).toHaveBeenLastCalledWith("Updated code block\n");
771 expect(highlight).toHaveBeenCalledTimes(2);
772 } finally {
773 await act(async () => renderer?.unmount());
774 vi.useRealTimers();
775 vi.unstubAllGlobals();
776 vi.restoreAllMocks();
777 }
778 });
779
780 it("reports the current task marker after reusing a renderer", async () => {
781 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
782 let renderer: ReactTestRenderer | undefined;
783 const taskChange = vi.fn();
784 const message = (text: string) => (
785 <ChatMarkdown cwd="/tmp/project" text={text} onTaskListChange={taskChange} />
786 );
787
788 try {
789 await act(async () => {
790 renderer = create(message("- [ ] First\n- [ ] Second"));
791 });
792 const mounted = renderer!;
793 const originalInput = mounted.root.findAllByType("input")[1]!;
794 await act(async () => {
795 mounted.update(message("- [ ] A longer first task\n- [ ] Second"));
796 });
797
798 const input = mounted.root.findAllByType("input")[1]!;
799 const listItem = mounted.root.findAllByType("li")[1]!;
800 const { onChange } = input.props as ComponentProps<"input">;
801 if (!onChange) throw new Error("Task checkbox has no edit handler");
802 await act(async () => {
803 onChange({
804 currentTarget: {
805 checked: true,
806 closest: () => ({
807 dataset: { taskMarkerOffset: String(listItem.props["data-task-marker-offset"]) },
808 }),
809 },
810 } as unknown as Parameters<typeof onChange>[0]);
811 });
812
813 expect(input).toBe(originalInput);
814 expect(taskChange).toHaveBeenCalledExactlyOnceWith({
815 markerOffset: "- [ ] A longer first task\n- ".length,
816 checked: true,
817 });
818 await act(async () => mounted.update(message("- [ ] A longer first task\n- [x] Second")));
819 expect(mounted.root.findAllByType("input")[1]!.props.checked).toBe(true);
820 } finally {
821 await act(async () => renderer?.unmount());
822 vi.unstubAllGlobals();
823 }
824 });
825});
826
827describe("canUseMarkdownFileShellActions", () => {
828 const environmentId = EnvironmentId.make("environment-1");
829
830 it("allows editor and file manager actions for local environments", () => {
831 expect(canUseMarkdownFileShellActions(environmentId, "local-exec", true)).toBe(true);
832 });
833
834 it("hides shell actions until the environment mode is resolved", () => {
835 expect(canUseMarkdownFileShellActions(environmentId, "local-exec", false)).toBe(false);
836 });
837
838 it("hides editor and file manager actions for remote environments", () => {
839 expect(canUseMarkdownFileShellActions(environmentId, "remote-links", true)).toBe(false);
840 expect(canUseMarkdownFileShellActions(environmentId, "remote-unavailable", true)).toBe(false);
841 });
842
843 it("hides shell actions when no environment owns the markdown", () => {
844 expect(canUseMarkdownFileShellActions(null, "local-exec", true)).toBe(false);
845 });
846});
847
848describe("hasMarkdownFilePrimaryAction", () => {
849 it("keeps the chip interactive when an editor, browser, or panel can open it", () => {
850 expect(
851 hasMarkdownFilePrimaryAction({
852 canOpenInEditor: true,
853 canOpenInBrowser: false,
854 canOpenInPanel: false,
855 }),
856 ).toBe(true);
857 expect(
858 hasMarkdownFilePrimaryAction({
859 canOpenInEditor: false,
860 canOpenInBrowser: true,
861 canOpenInPanel: false,
862 }),
863 ).toBe(true);
864 expect(
865 hasMarkdownFilePrimaryAction({
866 canOpenInEditor: false,
867 canOpenInBrowser: false,
868 canOpenInPanel: true,
869 }),
870 ).toBe(true);
871 });
872
873 it("removes the link affordance when no primary action can open the file", () => {
874 expect(
875 hasMarkdownFilePrimaryAction({
876 canOpenInEditor: false,
877 canOpenInBrowser: false,
878 canOpenInPanel: false,
879 }),
880 ).toBe(false);
881 });
882});
883
884describe("ChatMarkdown skill chips", () => {
885 it("updates digit-leading skill labels when discovered skills change", async () => {
886 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
887 let renderer: ReactTestRenderer | undefined;
888 const text = "Use $2spec with a $20k budget.";
889 try {
890 await act(async () => {
891 renderer = create(<ChatMarkdown cwd="/tmp/project" text={text} />);
892 });
893 const mounted = renderer!;
894 const labels = (label: string) =>
895 mounted.root.findAllByType("span").filter((node) => node.children.includes(label));
896 expect(labels("2Spec")).toHaveLength(0);
897
898 await act(async () => {
899 mounted.update(
900 <ChatMarkdown
901 cwd="/tmp/project"
902 text={text}
903 skills={[
904 { name: "2spec", displayName: "2Spec" },
905 { name: "20k", displayName: "MoneySkill" },
906 ]}
907 />,
908 );
909 });
910 expect(labels("2Spec")).toHaveLength(1);
911 expect(labels("MoneySkill")).toHaveLength(0);
912
913 await act(async () => {
914 mounted.update(<ChatMarkdown cwd="/tmp/project" text={text} skills={[]} />);
915 });
916 expect(labels("2Spec")).toHaveLength(0);
917 } finally {
918 await act(async () => renderer?.unmount());
919 vi.unstubAllGlobals();
920 }
921 });
922});
923
924describe("ChatMarkdown file option chips", () => {
925 it("keeps the fallback button text selectable", () => {
926 const html = renderToStaticMarkup(
927 <ChatMarkdown cwd="/tmp/project" text="[Source](/tmp/project/src/main.ts)" />,
928 );
929
930 expect(html).toContain("<button");
931 expect(html).toContain('aria-haspopup="menu"');
932 expect(html).toContain("select-text");
933 });
934
935 it.each([true, false])(
936 "renders Codex file citations as file chips with parseRawHtml=%s",
937 (parseRawHtml) => {
938 const html = renderToStaticMarkup(
939 <ChatMarkdown
940 cwd="/tmp/project"
941 text={
942 'Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx" purpose="output"}.'
943 }
944 lineBreaks={!parseRawHtml}
945 parseRawHtml={parseRawHtml}
946 />,
947 );
948
949 expect(html).not.toContain("codex-file-citation");
950 expect(html).toContain("chat-markdown-file-link");
951 expect(html).toContain(
952 'data-markdown-copy="[report.xlsx](/tmp/project/outputs/report.xlsx)"',
953 );
954 expect(html).toContain("report.xlsx");
955 },
956 );
957
958 it("leaves an unfinished streaming citation visible until it is complete", () => {
959 const html = renderToStaticMarkup(
960 <ChatMarkdown
961 cwd="/tmp/project"
962 text={'Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx"'}
963 isStreaming
964 />,
965 );
966
967 expect(html).toContain(":codex-file-citation");
968 expect(html).not.toContain("chat-markdown-file-link");
969 });
970
971 it("leaves malformed and similarly named file directives literal", () => {
972 for (const text of [
973 ':codex-file-citation{purpose="output"}',
974 ':codex-file-citation-extra{path="/tmp/project/outputs/report.xlsx"}',
975 ]) {
976 const html = renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text={text} />);
977
978 expect(html).toContain(text.replaceAll('"', "&quot;"));
979 expect(html).not.toContain("chat-markdown-file-link");
980 }
981 });
982
983 it("preserves Codex file citation examples inside code", () => {
984 const directive = ':codex-file-citation{path="/tmp/project/outputs/report.xlsx"}';
985 const html = renderToStaticMarkup(
986 <ChatMarkdown
987 cwd="/tmp/project"
988 text={`Example: \`${directive}\`\n\n\`\`\`text\n${directive}\n\`\`\``}
989 />,
990 );
991
992 expect(html.match(/:codex-file-citation/g)).toHaveLength(2);
993 expect(html).not.toContain("chat-markdown-file-link");
994 });
995
996 it("preserves escaped Codex file citations as literal text", () => {
997 const html = renderToStaticMarkup(
998 <ChatMarkdown
999 cwd="/tmp/project"
1000 text={'Example: \\:codex-file-citation{path="/tmp/project/outputs/report.xlsx"}'}
1001 />,
1002 );
1003
1004 expect(html).toContain(":codex-file-citation");
1005 expect(html).not.toContain("chat-markdown-file-link");
1006 });
1007
1008 it("does not create a nested link for citations inside link text", () => {
1009 const directive = ':codex-file-citation{path="/tmp/project/outputs/report.xlsx"}';
1010 const html = renderToStaticMarkup(
1011 <ChatMarkdown cwd="/tmp/project" text={`[See ${directive}](https://example.com)`} />,
1012 );
1013 const renderedText = html.replace(/<[^>]+>/g, "");
1014
1015 expect(renderedText).toContain("codex-file-citation");
1016 expect(html).not.toContain("chat-markdown-file-link");
1017 });
1018
1019 it("renders file citations created by over-indented list recovery", () => {
1020 const html = renderToStaticMarkup(
1021 <ChatMarkdown
1022 cwd="/tmp/project"
1023 text={'- Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx"}'}
1024 />,
1025 );
1026
1027 expect(html).not.toContain("<pre>");
1028 expect(html).toContain("Created ");
1029 expect(html).toContain("chat-markdown-file-link");
1030 expect(html).toContain("report.xlsx");
1031 });
1032
1033 it("disambiguates Codex citations with the same basename", () => {
1034 const html = renderToStaticMarkup(
1035 <ChatMarkdown
1036 cwd="/tmp/project"
1037 text={
1038 'Changed :codex-file-citation{path="/tmp/project/src/index.ts"} and :codex-file-citation{path="/tmp/project/test/index.ts"}.'
1039 }
1040 />,
1041 );
1042
1043 expect(html).toContain("index.ts · project/src");
1044 expect(html).toContain("index.ts · project/test");
1045 });
1046
1047 it("preserves rejected citations created by over-indented list recovery", () => {
1048 const malformedHtml = renderToStaticMarkup(
1049 <ChatMarkdown
1050 cwd="/tmp/project"
1051 text={'Leading text before list.\n\n- Bad :codex-file-citation{purpose="output"}'}
1052 />,
1053 );
1054 const nestedLinkHtml = renderToStaticMarkup(
1055 <ChatMarkdown
1056 cwd="/tmp/project"
1057 text={
1058 'Leading text before list.\n\n- [Bad :codex-file-citation{path="/tmp/project/report.xlsx"}](https://example.com)'
1059 }
1060 />,
1061 );
1062 const nestedLinkText = nestedLinkHtml.replace(/<[^>]+>/g, "");
1063
1064 expect(malformedHtml).toContain(
1065 "<li>Bad :codex-file-citation{purpose=&quot;output&quot;}</li>",
1066 );
1067 expect(nestedLinkText).toContain(
1068 "Bad :codex-file-citation{path=&quot;/tmp/project/report.xlsx&quot;}",
1069 );
1070 });
1071});
1072
1073describe("ChatMarkdown Codex follow-up suggestions", () => {
1074 it.each([true, false])("renders the label with parseRawHtml=%s", (parseRawHtml) => {
1075 const html = renderToStaticMarkup(
1076 <ChatMarkdown
1077 cwd="/tmp/project"
1078 text={
1079 'Done.\n\n- :codex-followup[Prepare print version]{prompt="Prepare the document for printing."}'
1080 }
1081 lineBreaks={!parseRawHtml}
1082 parseRawHtml={parseRawHtml}
1083 />,
1084 );
1085
1086 expect(html).toContain("Prepare print version");
1087 expect(html).not.toContain("codex-followup");
1088 expect(html).not.toContain("Prepare the document for printing.");
1089 });
1090
1091 it("keeps the label of a malformed follow-up created by over-indented list recovery", () => {
1092 const html = renderToStaticMarkup(
1093 <ChatMarkdown
1094 cwd="/tmp/project"
1095 text="- Next :codex-followup[Prepare print version]"
1096 />,
1097 );
1098
1099 expect(html).toContain("<li>Next :codex-followup[Prepare print version]</li>");
1100 });
1101});
1102
1103const ARTIFACT_TEMPLATE_DIRECTIVE =
1104 '::artifact-template{skill_name="artifact-template-hello-world" skill_directory="/Users/test/.codex/skills/artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}';
1105
1106describe("ChatMarkdown artifact-template cards", () => {
1107 it.each([true, false])("renders the Codex result card with parseRawHtml=%s", (parseRawHtml) => {
1108 const html = renderToStaticMarkup(
1109 <ChatMarkdown
1110 cwd="/tmp/project"
1111 text={ARTIFACT_TEMPLATE_DIRECTIVE}
1112 parseRawHtml={parseRawHtml}
1113 onUseArtifactTemplate={() => undefined}
1114 />,
1115 );
1116
1117 expect(html).not.toContain("::artifact-template");
1118 expect(html).toContain("data-chat-markdown-artifact-template");
1119 expect(html).toContain('data-artifact-kind="document"');
1120 expect(html).toContain('data-markdown-copy="Hello World (Document template)\n\n"');
1121 expect(html).toContain('data-skill-name="artifact-template-hello-world"');
1122 expect(html).toContain("Hello World");
1123 expect(html).toContain("Document template");
1124 expect(html).toContain("Use template");
1125 expect(html).not.toContain("<p><div");
1126 });
1127
1128 it("renders a passive card outside a composer-backed timeline", () => {
1129 const html = renderToStaticMarkup(
1130 <ChatMarkdown cwd="/tmp/project" text={ARTIFACT_TEMPLATE_DIRECTIVE} />,
1131 );
1132
1133 expect(html).toContain("data-chat-markdown-artifact-template");
1134 expect(html).not.toContain("Use template");
1135 });
1136
1137 it("leaves malformed and unfinished artifact-template directives literal", () => {
1138 const malformed =
1139 '::artifact-template{skill_name="artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}';
1140 const unfinished = ARTIFACT_TEMPLATE_DIRECTIVE.slice(0, -1);
1141
1142 for (const text of [malformed, unfinished]) {
1143 const html = renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text={text} />);
1144 expect(html).toContain("::artifact-template");
1145 expect(html).not.toContain("data-chat-markdown-artifact-template");
1146 }
1147 });
1148
1149 it("leaves escaped and similarly named artifact-template directives literal", () => {
1150 for (const text of [
1151 `\\${ARTIFACT_TEMPLATE_DIRECTIVE}`,
1152 ARTIFACT_TEMPLATE_DIRECTIVE.replace("::artifact-template", "::artifact-template-extra"),
1153 ]) {
1154 const html = renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text={text} />);
1155
1156 expect(html).toContain("::artifact-template");
1157 expect(html).not.toContain("data-chat-markdown-artifact-template");
1158 }
1159 });
1160
1161 it("preserves artifact-template examples inside code", () => {
1162 const html = renderToStaticMarkup(
1163 <ChatMarkdown
1164 cwd="/tmp/project"
1165 text={`\`${ARTIFACT_TEMPLATE_DIRECTIVE}\`\n\n\`\`\`text\n${ARTIFACT_TEMPLATE_DIRECTIVE}\n\`\`\``}
1166 />,
1167 );
1168
1169 expect(html.match(/::artifact-template/g)).toHaveLength(2);
1170 expect(html).not.toContain("data-chat-markdown-artifact-template");
1171 });
1172});
1173
1174describe("ChatMarkdown heading levels", () => {
1175 it("exposes headings below the host heading without changing their tags", () => {
1176 const html = renderToStaticMarkup(
1177 <ChatMarkdown
1178 cwd="/tmp/project"
1179 text={"# Top\n\n## Section\n\n###### Fine print"}
1180 headingLevelOffset={3}
1181 />,
1182 );
1183
1184 expect(html).toContain('<h1 id="user-content-top" aria-level="4">Top</h1>');
1185 expect(html).toContain('<h2 id="user-content-section" aria-level="5">Section</h2>');
1186 expect(html).toContain('<h6 id="user-content-fine-print" aria-level="6">Fine print</h6>');
1187 });
1188
1189 it("leaves heading levels alone when the markdown is not nested", () => {
1190 const html = renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text="# Top" />);
1191
1192 expect(html).toContain('<h1 id="user-content-top">Top</h1>');
1193 });
1194});
1195
1196describe("shouldUseMarkdownFileBrowserPrimaryAction", () => {
1197 it("uses the browser when it is the only available primary action", () => {
1198 expect(
1199 shouldUseMarkdownFileBrowserPrimaryAction({
1200 iconPath: "/tmp/report.html",
1201 canOpenInEditor: false,
1202 canOpenInBrowser: true,
1203 canOpenInPanel: false,
1204 }),
1205 ).toBe(true);
1206 });
1207
1208 it("preserves the normal editor and panel defaults for HTML files", () => {
1209 expect(
1210 shouldUseMarkdownFileBrowserPrimaryAction({
1211 iconPath: "/tmp/report.html",
1212 canOpenInEditor: true,
1213 canOpenInBrowser: true,
1214 canOpenInPanel: false,
1215 }),
1216 ).toBe(false);
1217 expect(
1218 shouldUseMarkdownFileBrowserPrimaryAction({
1219 iconPath: "/tmp/report.html",
1220 canOpenInEditor: false,
1221 canOpenInBrowser: true,
1222 canOpenInPanel: true,
1223 }),
1224 ).toBe(false);
1225 });
1226
1227 it("continues to open PDF files in the browser by default", () => {
1228 expect(
1229 shouldUseMarkdownFileBrowserPrimaryAction({
1230 iconPath: "/tmp/report.pdf",
1231 canOpenInEditor: true,
1232 canOpenInBrowser: true,
1233 canOpenInPanel: true,
1234 }),
1235 ).toBe(true);
1236 });
1237});
1238
1239describe("ChatMarkdown Windows file links", () => {
1240 const environmentId = EnvironmentId.make("env-windows");
1241
1242 it.each([true, false])("preserves drive paths with parseRawHtml=%s", (parseRawHtml) => {
1243 const html = renderToStaticMarkup(
1244 <ChatMarkdown
1245 cwd="C:/Users/shawn/project"
1246 environmentId={environmentId}
1247 text="[Open](C:/Users/shawn/project/src/main.ts)"
1248 lineBreaks={!parseRawHtml}
1249 parseRawHtml={parseRawHtml}
1250 />,
1251 );
1252
1253 expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"');
1254 expect(html).toContain("chat-markdown-file-link");
1255 });
1256
1257 it.each([true, false])("normalizes backslashes with parseRawHtml=%s", (parseRawHtml) => {
1258 const html = renderToStaticMarkup(
1259 <ChatMarkdown
1260 cwd="C:/Users/shawn/project"
1261 environmentId={environmentId}
1262 text={String.raw`[Open](C:\Users\shawn\project\src\main.ts)`}
1263 lineBreaks={!parseRawHtml}
1264 parseRawHtml={parseRawHtml}
1265 />,
1266 );
1267
1268 expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"');
1269 expect(html).toContain("chat-markdown-file-link");
1270 });
1271
1272 it.each([true, false])(
1273 "keeps backslashes CommonMark would read as escapes with parseRawHtml=%s",
1274 (parseRawHtml) => {
1275 const html = renderToStaticMarkup(
1276 <ChatMarkdown
1277 cwd="C:/Users/shawn/project"
1278 environmentId={environmentId}
1279 text={String.raw`[settings](C:\Users\shawn\.claude\settings.json)`}
1280 lineBreaks={!parseRawHtml}
1281 parseRawHtml={parseRawHtml}
1282 />,
1283 );
1284
1285 expect(html).toContain('href="C:/Users/shawn/.claude/settings.json"');
1286 },
1287 );
1288
1289 it.each([true, false])(
1290 "distinguishes same-named backslash paths with parseRawHtml=%s",
1291 (parseRawHtml) => {
1292 const html = renderToStaticMarkup(
1293 <ChatMarkdown
1294 cwd="C:/Users/shawn/project"
1295 environmentId={environmentId}
1296 text={String.raw`[index.ts](C:\Users\shawn\project\src\index.ts) and [index.ts](C:\Users\shawn\project\test\index.ts)`}
1297 lineBreaks={!parseRawHtml}
1298 parseRawHtml={parseRawHtml}
1299 />,
1300 );
1301
1302 expect(html).toContain("index.ts · project/src");
1303 expect(html).toContain("index.ts · project/test");
1304 },
1305 );
1306
1307 it.each([true, false])(
1308 "does not disambiguate the same file in links and inline code with parseRawHtml=%s",
1309 (parseRawHtml) => {
1310 const path = String.raw`C:\Users\shawn\project\src\main.ts`;
1311 const html = renderToStaticMarkup(
1312 <ChatMarkdown
1313 cwd="C:/Users/shawn/project"
1314 environmentId={environmentId}
1315 text={`[Source](${path}) and \`${path}\``}
1316 lineBreaks={!parseRawHtml}
1317 parseRawHtml={parseRawHtml}
1318 />,
1319 );
1320
1321 expect(html.match(/chat-markdown-file-link/g)).toHaveLength(2);
1322 expect(html).not.toContain("main.ts ·");
1323 },
1324 );
1325
1326 it.each([true, false])("preserves reference links with parseRawHtml=%s", (parseRawHtml) => {
1327 const html = renderToStaticMarkup(
1328 <ChatMarkdown
1329 cwd="C:/Users/shawn/project"
1330 environmentId={environmentId}
1331 text={"[Open][source]\n\n[source]: C:/Users/shawn/project/src/main.ts"}
1332 lineBreaks={!parseRawHtml}
1333 parseRawHtml={parseRawHtml}
1334 />,
1335 );
1336
1337 expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"');
1338 expect(html).toContain("chat-markdown-file-link");
1339 });
1340
1341 it.each([true, false])("still rejects unsafe schemes with parseRawHtml=%s", (parseRawHtml) => {
1342 const html = renderToStaticMarkup(
1343 <ChatMarkdown
1344 cwd="C:/Users/shawn/project"
1345 environmentId={environmentId}
1346 text="[unsafe](javascript:alert(1)) and [unknown](d:alert(1))"
1347 lineBreaks={!parseRawHtml}
1348 parseRawHtml={parseRawHtml}
1349 />,
1350 );
1351
1352 expect(html).not.toContain("javascript:");
1353 expect(html).not.toContain("d:alert");
1354 expect(html).not.toContain("chat-markdown-file-link");
1355 });
1356});
1357
1358it("opens a disclosure only when find selects a match inside it", async () => {
1359 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
1360 vi.stubGlobal(
1361 "Highlight",
1362 class extends Set<Range> {
1363 constructor(...ranges: Range[]) {
1364 super(ranges);
1365 }
1366 },
1367 );
1368 const highlights = new Map<string, Set<Range>>();
1369 vi.stubGlobal("CSS", { highlights, escape: (value: string) => value });
1370 const container = document.createElement("div");
1371 document.body.append(container);
1372 const root = createRoot(container);
1373 const openStates = () =>
1374 [...container.querySelectorAll("[data-markdown-details-open]")].map((node) =>
1375 node.getAttribute("data-markdown-details-open"),
1376 );
1377 function Probe({
1378 activeOccurrence,
1379 searching = true,
1380 }: {
1381 activeOccurrence: number;
1382 searching?: boolean;
1383 }) {
1384 useThreadFindHighlights({
1385 container,
1386 query: searching ? "needle" : "",
1387 activeRowId: "row",
1388 activeOccurrence,
1389 onActiveRange: () => {},
1390 });
1391 return (
1392 <div data-timeline-row-id="row">
1393 <div data-thread-find-text>
1394 <MarkdownFindContext value={searching}>
1395 <ChatMarkdown
1396 cwd={undefined}
1397 text={[
1398 "Visible needle.",
1399 "<details><summary>Unrelated</summary><p>nothing here</p></details>",
1400 "<details><summary>Outer</summary><details><summary>Inner</summary><p>needle</p></details></details>",
1401 ].join("\n\n")}
1402 />
1403 </MarkdownFindContext>
1404 </div>
1405 </div>
1406 );
1407 }
1408 const frame = () =>
1409 act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
1410 try {
1411 // Closed panels are unmounted until find starts, as in the app.
1412 await act(() => root.render(<Probe activeOccurrence={0} searching={false} />));
1413 expect(container.textContent).not.toContain("nothing here");
1414 await act(() => root.render(<Probe activeOccurrence={0} />));
1415 await frame();
1416 // Selecting the visible match opens nothing; the folded one is counted but not painted.
1417 expect(openStates()).toEqual(["false", "false", "false"]);
1418 expect(container.textContent).toContain("nothing here");
1419 expect(
1420 [...(highlights.get("t3-thread-find-active") ?? [])].map((range) => range.toString()),
1421 ).toEqual(["needle"]);
1422 expect(highlights.get("t3-thread-find")?.size).toBe(0);
1423
1424 await act(() => root.render(<Probe activeOccurrence={1} />));
1425 await frame();
1426 await frame();
1427 // Stepping to the folded match opens its two ancestors, not the unrelated one.
1428 expect(openStates()).toEqual(["false", "true", "true"]);
1429 expect(highlights.get("t3-thread-find-active")?.size).toBe(1);
1430 } finally {
1431 await act(() => root.unmount());
1432 container.remove();
1433 vi.unstubAllGlobals();
1434 }
1435});
1436
1437it("keeps Mermaid diagrams rendered until find selects a match in their source", async () => {
1438 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
1439 vi.stubGlobal(
1440 "Highlight",
1441 class extends Set<Range> {
1442 constructor(...ranges: Range[]) {
1443 super(ranges);
1444 }
1445 },
1446 );
1447 const highlights = new Map<string, Set<Range>>();
1448 vi.stubGlobal("CSS", { highlights, escape: (value: string) => value });
1449 const container = document.createElement("div");
1450 document.body.append(container);
1451 const root = createRoot(container);
1452 const diagram = "```mermaid\ngraph TD; Alpha-->Beta\n```";
1453 function Probe({ query }: { query: string }) {
1454 useThreadFindHighlights({
1455 container,
1456 query,
1457 activeRowId: "row",
1458 activeOccurrence: 0,
1459 onActiveRange: () => {},
1460 });
1461 return (
1462 <div data-timeline-row-id="row">
1463 <div data-thread-find-text>
1464 <MarkdownFindContext value={true}>
1465 <ChatMarkdown cwd={undefined} text={`Needle first.\n\n${diagram}\n\n${diagram}`} />
1466 </MarkdownFindContext>
1467 </div>
1468 </div>
1469 );
1470 }
1471 const frame = () =>
1472 act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
1473 const diagrams = () => container.querySelectorAll('svg[aria-label="Diagram"]').length;
1474 try {
1475 await act(() => root.render(<Probe query="Needle" />));
1476 await frame();
1477 expect(diagrams()).toBe(2);
1478 await act(() => root.render(<Probe query="Alpha" />));
1479 await frame();
1480 await frame();
1481 // Only the diagram holding the selected match switches to source.
1482 expect(diagrams()).toBe(1);
1483 expect(
1484 [...(highlights.get("t3-thread-find-active") ?? [])].map((range) => range.toString()),
1485 ).toEqual(["Alpha"]);
1486 } finally {
1487 await act(() => root.unmount());
1488 container.remove();
1489 vi.unstubAllGlobals();
1490 }
1491});
1492
1493it.each([
1494 {
1495 text: "```mermaid\ngraph TD; SearchSourceAlpha-->B\n```",
1496 query: "SearchSourceAlpha",
1497 count: 1,
1498 },
1499 {
1500 text: "★ Insight ─────\nfirst line\nsecond line",
1501 query: "first line second",
1502 count: 0,
1503 lineBreaks: true,
1504 },
1505 { text: '```ts title="src/needle.ts"\nconst a = 1;\n```', query: "needle", count: 0 },
1506 { text: "```weirdlang\nconst a = 1;\n```", query: "weirdlang", count: 0 },
1507 { text: "Use $test-t3-app now", query: "T3 App Testing", count: 1 },
1508 { text: "`/tmp/file.ts:42`", query: "file.ts · L42", count: 1, user: true, lineBreaks: true },
1509 { text: "> [!NOTE]\n> Searchable alert", query: "Searchable alert", count: 1 },
1510 {
1511 text: "<details><summary>Folded</summary><p>Hidden needle</p></details>",
1512 query: "Hidden needle",
1513 count: 1,
1514 },
1515 { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "Hello World", count: 1, useTemplate: true },
1516 { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "Document template", count: 1, useTemplate: true },
1517 { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "World Document", count: 0, useTemplate: true },
1518 { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "Use template", count: 0, useTemplate: true },
1519])(
1520 "highlights the indexed occurrences of $query in $text",
1521 async ({ text, query, count, lineBreaks, user, useTemplate }) => {
1522 const skills = [{ name: "test-t3-app", displayName: "T3 App Testing" }];
1523 const highlights = new Map<string, Set<Range>>();
1524 vi.stubGlobal(
1525 "Highlight",
1526 class extends Set<Range> {
1527 constructor(...ranges: Range[]) {
1528 super(ranges);
1529 }
1530 },
1531 );
1532 vi.stubGlobal("CSS", { highlights, escape: (value: string) => value });
1533 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
1534 const container = document.createElement("div");
1535 document.body.append(container);
1536 const root = createRoot(container);
1537 function Probe() {
1538 useThreadFindHighlights({
1539 container,
1540 query,
1541 activeRowId: "row",
1542 activeOccurrence: 0,
1543 onActiveRange: () => {},
1544 });
1545 return (
1546 <div data-timeline-row-id="row">
1547 <div data-thread-find-text>
1548 <MarkdownFindContext value={true}>
1549 <ChatMarkdown
1550 text={text}
1551 cwd={undefined}
1552 skills={skills}
1553 lineBreaks={lineBreaks ?? false}
1554 parseRawHtml={!user}
1555 onUseArtifactTemplate={useTemplate ? () => undefined : undefined}
1556 />
1557 </MarkdownFindContext>
1558 </div>
1559 </div>
1560 );
1561 }
1562 try {
1563 await act(() => root.render(<Probe />));
1564 await act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
1565 const ranges = [...highlights.values()].flatMap((value) => [...value]);
1566 expect(ranges.map((range) => range.toString())).toEqual(
1567 Array.from({ length: count }, () => query),
1568 );
1569 const segments =
1570 searchableMessageSegments(
1571 { role: user ? "user" : "assistant", text, streaming: false },
1572 undefined,
1573 skills,
1574 ) ?? [];
1575 expect(
1576 segments.reduce((sum, segment) => sum + countThreadSearchOccurrences(segment, query), 0),
1577 ).toBe(count);
1578 } finally {
1579 await act(() => root.unmount());
1580 container.remove();
1581 vi.unstubAllGlobals();
1582 }
1583 },
1584);
1585
1586describe("ChatMarkdown heading ids", () => {
1587 it("never gives two headings the same id, even when a suffix matches another heading", () => {
1588 const html = renderToStaticMarkup(
1589 <ChatMarkdown
1590 cwd="/tmp/project"
1591 parseRawHtml
1592 text={
1593 '## Setup\n\n## Setup\n\n## Setup-1\n\n<h2 id="install-1">Pinned</h2>\n\n## Install\n\n## Install'
1594 }
1595 />,
1596 );
1597 const ids = [...html.matchAll(/<h2 id="([^"]+)"/g)].map((match) => match[1]);
1598 expect(ids).toEqual([
1599 "user-content-setup",
1600 "user-content-setup-1",
1601 "user-content-setup-1-1",
1602 "user-content-install-1",
1603 "user-content-install",
1604 "user-content-install-2",
1605 ]);
1606 expect(new Set(ids).size).toBe(ids.length);
1607 });
1608});
1609
1610describe("ChatMarkdown in-page links", () => {
1611 it.each([true, false])(
1612 "scrolls a table-of-contents link to its heading without touching the URL (parseRawHtml=%s)",
1613 async (parseRawHtml) => {
1614 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
1615 const { createRoot } = await import("react-dom/client");
1616 const container = document.createElement("div");
1617 document.body.append(container);
1618 const root = createRoot(container);
1619 window.history.replaceState(null, "", "/#/env/thread");
1620 const scrollIntoView = vi.fn();
1621 HTMLElement.prototype.scrollIntoView = scrollIntoView;
1622 try {
1623 await act(async () => {
1624 root.render(
1625 <ChatMarkdown
1626 cwd="/tmp/project"
1627 parseRawHtml={parseRawHtml}
1628 text={
1629 "- [Operating model](#1-operating-model)\n- [Missing](#nowhere)\n\n## 1. Operating model\n\n## 1. Operating model"
1630 }
1631 />,
1632 );
1633 });
1634 const headings = [...container.querySelectorAll("h2")];
1635 expect(headings.map((heading) => heading.id)).toEqual([
1636 "user-content-1-operating-model",
1637 "user-content-1-operating-model-1",
1638 ]);
1639
1640 const [tocLink, missingLink] = [...container.querySelectorAll("a")];
1641 const click = () => new MouseEvent("click", { bubbles: true, cancelable: true });
1642 const tocClick = click();
1643 tocLink!.dispatchEvent(tocClick);
1644 expect(tocClick.defaultPrevented).toBe(true);
1645 expect(scrollIntoView.mock.contexts).toEqual([headings[0]]);
1646
1647 const missingClick = click();
1648 missingLink!.dispatchEvent(missingClick);
1649 expect(missingClick.defaultPrevented).toBe(true);
1650 expect(scrollIntoView).toHaveBeenCalledTimes(1);
1651 expect(window.location.hash).toBe("#/env/thread");
1652 } finally {
1653 await act(async () => root.unmount());
1654 container.remove();
1655 }
1656 },
1657 );
1658});