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 @@...@@ -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
5describe("orderedListGutterStyle", () => {21describe("orderedListGutterStyle", () => {
6 it("leaves the default gutter alone for single-digit lists", () => {22 it("leaves the default gutter alone for single-digit lists", () => {
...@@ -42,3 +58,97 @@ describe("orderedListGutterStyle", () => {...@@ -42,3 +58,97 @@ describe("orderedListGutterStyle", () => {
42 expect(orderedListGutterStyle(0, 100)).toEqual({ "--list-gutter": "4ch" });58 expect(orderedListGutterStyle(0, 100)).toEqual({ "--list-gutter": "4ch" });
43 });59 });
44});60});
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 {...@@ -128,6 +128,7 @@ interface ChatMarkdownProps {
128const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];128const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
129129
130const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/;130const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/;
131const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/;
131const MAX_HIGHLIGHT_CACHE_ENTRIES = 500;132const MAX_HIGHLIGHT_CACHE_ENTRIES = 500;
132const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024;133const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024;
133134
...@@ -197,7 +198,7 @@ function rehypeNormalizeWindowsImageSrc() {...@@ -197,7 +198,7 @@ function rehypeNormalizeWindowsImageSrc() {
197 node.type === "element" &&198 node.type === "element" &&
198 node.tagName === "img" &&199 node.tagName === "img" &&
199 typeof src === "string" &&200 typeof src === "string" &&
200 /^[A-Za-z]:[\\/]/.test(src)201 WINDOWS_DRIVE_PATH_REGEX.test(src)
201 ) {202 ) {
202 node.properties = {203 node.properties = {
203 ...node.properties,204 ...node.properties,
...@@ -231,7 +232,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [...@@ -231,7 +232,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [
231 remarkGithubAlerts,232 remarkGithubAlerts,
232 remarkNormalizeListItemIndentation,233 remarkNormalizeListItemIndentation,
233 remarkPreserveCodeMeta,234 remarkPreserveCodeMeta,
234 remarkTagInlineCode,235 remarkNormalizeLinksAndTagInlineCode,
235] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;236] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
236237
237const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [238const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
...@@ -240,7 +241,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [...@@ -240,7 +241,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
240 remarkNormalizeListItemIndentation,241 remarkNormalizeListItemIndentation,
241 remarkBreaks,242 remarkBreaks,
242 remarkPreserveCodeMeta,243 remarkPreserveCodeMeta,
243 remarkTagInlineCode,244 remarkNormalizeLinksAndTagInlineCode,
244] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;245] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
245246
246const CHAT_MARKDOWN_REHYPE_PLUGINS = [247const CHAT_MARKDOWN_REHYPE_PLUGINS = [
...@@ -326,6 +327,7 @@ function extractPreCodeMeta(node: unknown): string | undefined {...@@ -326,6 +327,7 @@ function extractPreCodeMeta(node: unknown): string | undefined {
326type MarkdownAstNode = {327type MarkdownAstNode = {
327 type?: string;328 type?: string;
328 meta?: unknown;329 meta?: unknown;
330 url?: string;
329 data?: {331 data?: {
330 hProperties?: Record<string, unknown>;332 hProperties?: Record<string, unknown>;
331 };333 };
...@@ -352,15 +354,20 @@ function remarkPreserveCodeMeta() {...@@ -352,15 +354,20 @@ function remarkPreserveCodeMeta() {
352}354}
353355
354/**356/**
355 * Fenced code also lands on the `code` component, and inline vs block is no357 * Preserve Windows drive links as allowed `file:` URLs before sanitization.
356 * longer distinguishable there once both render `<code>` — so inline spans are358 * The same traversal tags inline code while it can still be distinguished
357 * tagged on the mdast, where the distinction still exists. Code inside a link359 * from fenced code. Code inside links stays untagged to avoid nested anchors.
358 * label stays untagged: linkifying it would nest an anchor inside the link's
359 * anchor and steal its clicks.
360 */360 */
361function remarkTagInlineCode() {361function remarkNormalizeLinksAndTagInlineCode() {
362 return (tree: MarkdownAstNode) => {362 return (tree: MarkdownAstNode) => {
363 const visit = (node: MarkdownAstNode, insideLink: boolean) => {363 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 }
364 if (node.type === "inlineCode" && !insideLink) {371 if (node.type === "inlineCode" && !insideLink) {
365 node.data = {372 node.data = {
366 ...node.data,373 ...node.data,
...@@ -875,14 +882,12 @@ function pathParentSegments(path: string): string[] {...@@ -875,14 +882,12 @@ function pathParentSegments(path: string): string[] {
875function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray<string>): Map<string, string> {882function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray<string>): Map<string, string> {
876 const groups = new Map<string, Set<string>>();883 const groups = new Map<string, Set<string>>();
877 for (const filePath of filePaths) {884 for (const filePath of filePaths) {
878 const pathSegments = filePath885 const normalizedPath = filePath.replaceAll("\\", "/");
879 .replaceAll("\\", "/")886 const pathSegments = normalizedPath.split("/").filter((segment) => segment.length > 0);
880 .split("/")
881 .filter((segment) => segment.length > 0);
882 const basename = pathSegments[pathSegments.length - 1];887 const basename = pathSegments[pathSegments.length - 1];
883 if (!basename) continue;888 if (!basename) continue;
884 const group = groups.get(basename) ?? new Set<string>();889 const group = groups.get(basename) ?? new Set<string>();
885 group.add(filePath);890 group.add(normalizedPath);
886 groups.set(basename, group);891 groups.set(basename, group);
887 }892 }
888893
...@@ -943,7 +948,10 @@ function extractInlineCodeSpans(text: string): string[] {...@@ -943,7 +948,10 @@ function extractInlineCodeSpans(text: string): string[] {
943948
944function normalizeMarkdownLinkHrefKey(href: string): string {949function normalizeMarkdownLinkHrefKey(href: string): string {
945 const normalizedHref = normalizeMarkdownLinkDestination(href);950 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;
947}955}
948956
949const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none";957const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none";
...@@ -1605,7 +1613,9 @@ function ChatMarkdown({...@@ -1605,7 +1613,9 @@ function ChatMarkdown({
1605 copyMarkdown: string,1613 copyMarkdown: string,
1606 className?: string,1614 className?: string,
1607 ) => {1615 ) => {
1608 const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath);1616 const parentSuffix = fileLinkParentSuffixByPath.get(
1617 fileLinkMeta.filePath.replaceAll("\\", "/"),
1618 );
1609 const labelParts = [fileLinkMeta.basename];1619 const labelParts = [fileLinkMeta.basename];
1610 if (typeof parentSuffix === "string" && parentSuffix.length > 0) {1620 if (typeof parentSuffix === "string" && parentSuffix.length > 0) {
1611 labelParts.push(parentSuffix);1621 labelParts.push(parentSuffix);