authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-24 14:45:16-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 14:45:16-07:00
loga09f92171186677796ccdc28148406c7491601ce
treeffd2199e0830b4e00e460296d44089b33951c29a
parent883e1a3cd9d70f6906a8eb1458a7a05d1d9e0d55
signature Signed by PGP key B5690EEEBB952194

fix(web): make Windows file links clickable in chat (#8081)


2 files changed, 138 insertions(+), 18 deletions(-)

apps/web/src/components/ChatMarkdown.test.tsx+112-2
......@@ -1,6 +1,22 @@
1import { describe, expect, it } from "vite-plus/test";
1import { EnvironmentId } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it, vi } from "vite-plus/test";
24
3import { orderedListGutterStyle } from "./ChatMarkdown";
5vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
6vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
7vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() }));
8vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
9vi.mock("../state/session", async (importOriginal) => ({
10 ...(await importOriginal<typeof import("../state/session")>()),
11 usePreparedConnection: () => ({ _tag: "Loading" }),
12}));
13vi.mock("../state/entities", () => ({
14 useActiveEnvironmentId: () => EnvironmentId.make("env-windows"),
15}));
16vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() }));
17vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn() }));
18
19import ChatMarkdown, { orderedListGutterStyle } from "./ChatMarkdown";
420
521describe("orderedListGutterStyle", () => {
622 it("leaves the default gutter alone for single-digit lists", () => {
......@@ -42,3 +58,97 @@ describe("orderedListGutterStyle", () => {
4258 expect(orderedListGutterStyle(0, 100)).toEqual({ "--list-gutter": "4ch" });
4359 });
4460});
61
62describe("ChatMarkdown Windows file links", () => {
63 it.each([true, false])("preserves drive paths with parseRawHtml=%s", (parseRawHtml) => {
64 const html = renderToStaticMarkup(
65 <ChatMarkdown
66 cwd="C:/Users/shawn/project"
67 text="[Open](C:/Users/shawn/project/src/main.ts)"
68 lineBreaks={!parseRawHtml}
69 parseRawHtml={parseRawHtml}
70 />,
71 );
72
73 expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"');
74 expect(html).toContain("chat-markdown-file-link");
75 });
76
77 it.each([true, false])("normalizes backslashes with parseRawHtml=%s", (parseRawHtml) => {
78 const html = renderToStaticMarkup(
79 <ChatMarkdown
80 cwd="C:/Users/shawn/project"
81 text={String.raw`[Open](C:\Users\shawn\project\src\main.ts)`}
82 lineBreaks={!parseRawHtml}
83 parseRawHtml={parseRawHtml}
84 />,
85 );
86
87 expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"');
88 expect(html).toContain("chat-markdown-file-link");
89 });
90
91 it.each([true, false])(
92 "distinguishes same-named backslash paths with parseRawHtml=%s",
93 (parseRawHtml) => {
94 const html = renderToStaticMarkup(
95 <ChatMarkdown
96 cwd="C:/Users/shawn/project"
97 text={String.raw`[Source](C:\Users\shawn\project\src\index.ts) and [Test](C:\Users\shawn\project\test\index.ts)`}
98 lineBreaks={!parseRawHtml}
99 parseRawHtml={parseRawHtml}
100 />,
101 );
102
103 expect(html).toContain("index.ts · project/src");
104 expect(html).toContain("index.ts · project/test");
105 },
106 );
107
108 it.each([true, false])(
109 "does not disambiguate the same file in links and inline code with parseRawHtml=%s",
110 (parseRawHtml) => {
111 const path = String.raw`C:\Users\shawn\project\src\main.ts`;
112 const html = renderToStaticMarkup(
113 <ChatMarkdown
114 cwd="C:/Users/shawn/project"
115 text={`[Source](${path}) and \`${path}\``}
116 lineBreaks={!parseRawHtml}
117 parseRawHtml={parseRawHtml}
118 />,
119 );
120
121 expect(html.match(/chat-markdown-file-link/g)).toHaveLength(2);
122 expect(html).not.toContain("main.ts ·");
123 },
124 );
125
126 it.each([true, false])("preserves reference links with parseRawHtml=%s", (parseRawHtml) => {
127 const html = renderToStaticMarkup(
128 <ChatMarkdown
129 cwd="C:/Users/shawn/project"
130 text={"[Open][source]\n\n[source]: C:/Users/shawn/project/src/main.ts"}
131 lineBreaks={!parseRawHtml}
132 parseRawHtml={parseRawHtml}
133 />,
134 );
135
136 expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"');
137 expect(html).toContain("chat-markdown-file-link");
138 });
139
140 it.each([true, false])("still rejects unsafe schemes with parseRawHtml=%s", (parseRawHtml) => {
141 const html = renderToStaticMarkup(
142 <ChatMarkdown
143 cwd="C:/Users/shawn/project"
144 text="[unsafe](javascript:alert(1)) and [unknown](d:alert(1))"
145 lineBreaks={!parseRawHtml}
146 parseRawHtml={parseRawHtml}
147 />,
148 );
149
150 expect(html).not.toContain("javascript:");
151 expect(html).not.toContain("d:alert");
152 expect(html).not.toContain("chat-markdown-file-link");
153 });
154});
apps/web/src/components/ChatMarkdown.tsx+26-16
......@@ -128,6 +128,7 @@ interface ChatMarkdownProps {
128128const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
129129
130130const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/;
131const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/;
131132const MAX_HIGHLIGHT_CACHE_ENTRIES = 500;
132133const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024;
133134
......@@ -197,7 +198,7 @@ function rehypeNormalizeWindowsImageSrc() {
197198 node.type === "element" &&
198199 node.tagName === "img" &&
199200 typeof src === "string" &&
200 /^[A-Za-z]:[\\/]/.test(src)
201 WINDOWS_DRIVE_PATH_REGEX.test(src)
201202 ) {
202203 node.properties = {
203204 ...node.properties,
......@@ -231,7 +232,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [
231232 remarkGithubAlerts,
232233 remarkNormalizeListItemIndentation,
233234 remarkPreserveCodeMeta,
234 remarkTagInlineCode,
235 remarkNormalizeLinksAndTagInlineCode,
235236] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
236237
237238const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
......@@ -240,7 +241,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
240241 remarkNormalizeListItemIndentation,
241242 remarkBreaks,
242243 remarkPreserveCodeMeta,
243 remarkTagInlineCode,
244 remarkNormalizeLinksAndTagInlineCode,
244245] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
245246
246247const CHAT_MARKDOWN_REHYPE_PLUGINS = [
......@@ -326,6 +327,7 @@ function extractPreCodeMeta(node: unknown): string | undefined {
326327type MarkdownAstNode = {
327328 type?: string;
328329 meta?: unknown;
330 url?: string;
329331 data?: {
330332 hProperties?: Record<string, unknown>;
331333 };
......@@ -352,15 +354,20 @@ function remarkPreserveCodeMeta() {
352354}
353355
354356/**
355 * Fenced code also lands on the `code` component, and inline vs block is no
356 * longer distinguishable there once both render `<code>` — so inline spans are
357 * tagged on the mdast, where the distinction still exists. Code inside a link
358 * label stays untagged: linkifying it would nest an anchor inside the link's
359 * anchor and steal its clicks.
357 * Preserve Windows drive links as allowed `file:` URLs before sanitization.
358 * The same traversal tags inline code while it can still be distinguished
359 * from fenced code. Code inside links stays untagged to avoid nested anchors.
360360 */
361function remarkTagInlineCode() {
361function remarkNormalizeLinksAndTagInlineCode() {
362362 return (tree: MarkdownAstNode) => {
363363 const visit = (node: MarkdownAstNode, insideLink: boolean) => {
364 if (
365 (node.type === "link" || node.type === "definition") &&
366 typeof node.url === "string" &&
367 WINDOWS_DRIVE_PATH_REGEX.test(node.url)
368 ) {
369 node.url = `file:///${node.url.replaceAll("\\", "/")}`;
370 }
364371 if (node.type === "inlineCode" && !insideLink) {
365372 node.data = {
366373 ...node.data,
......@@ -875,14 +882,12 @@ function pathParentSegments(path: string): string[] {
875882function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray<string>): Map<string, string> {
876883 const groups = new Map<string, Set<string>>();
877884 for (const filePath of filePaths) {
878 const pathSegments = filePath
879 .replaceAll("\\", "/")
880 .split("/")
881 .filter((segment) => segment.length > 0);
885 const normalizedPath = filePath.replaceAll("\\", "/");
886 const pathSegments = normalizedPath.split("/").filter((segment) => segment.length > 0);
882887 const basename = pathSegments[pathSegments.length - 1];
883888 if (!basename) continue;
884889 const group = groups.get(basename) ?? new Set<string>();
885 group.add(filePath);
890 group.add(normalizedPath);
886891 groups.set(basename, group);
887892 }
888893
......@@ -943,7 +948,10 @@ function extractInlineCodeSpans(text: string): string[] {
943948
944949function normalizeMarkdownLinkHrefKey(href: string): string {
945950 const normalizedHref = normalizeMarkdownLinkDestination(href);
946 return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref;
951 const rewrittenHref = rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref;
952 return WINDOWS_DRIVE_PATH_REGEX.test(rewrittenHref)
953 ? rewrittenHref.replaceAll("\\", "/")
954 : rewrittenHref;
947955}
948956
949957const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none";
......@@ -1605,7 +1613,9 @@ function ChatMarkdown({
16051613 copyMarkdown: string,
16061614 className?: string,
16071615 ) => {
1608 const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath);
1616 const parentSuffix = fileLinkParentSuffixByPath.get(
1617 fileLinkMeta.filePath.replaceAll("\\", "/"),
1618 );
16091619 const labelParts = [fileLinkMeta.basename];
16101620 if (typeof parentSuffix === "string" && parentSuffix.length > 0) {
16111621 labelParts.push(parentSuffix);