From 67173439c0b52c481a4f83a186d9ce4625f85234 Mon Sep 17 00:00:00 2001 From: "Arif Rahman (Bolt)" <32804415+peculiarnewbie@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:28:04 +0700 Subject: [PATCH 1/3] fix(web): make Windows file links clickable --- apps/web/src/components/ChatMarkdown.tsx | 3 ++ apps/web/src/filePathDisplay.test.ts | 9 +++++ apps/web/src/filePathDisplay.ts | 6 ++- .../web/src/markdown-links-rendering.test.tsx | 40 +++++++++++++++++++ apps/web/src/markdown-links.test.ts | 8 ++++ apps/web/src/markdown-links.ts | 28 +++++++++++++ 6 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/markdown-links-rendering.test.tsx diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 8dc545d31eb0..fcdd8eecb3a0 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -75,6 +75,7 @@ import { import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { normalizeMarkdownLinkDestination, + remarkRewriteWindowsFileLinks, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, @@ -162,6 +163,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, + remarkRewriteWindowsFileLinks, remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkPreserveCodeMeta, @@ -170,6 +172,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkGfm, + remarkRewriteWindowsFileLinks, remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkBreaks, diff --git a/apps/web/src/filePathDisplay.test.ts b/apps/web/src/filePathDisplay.test.ts index ecceea09ca1e..ca2287150dfa 100644 --- a/apps/web/src/filePathDisplay.test.ts +++ b/apps/web/src/filePathDisplay.test.ts @@ -21,6 +21,15 @@ describe("formatWorkspaceRelativePath", () => { ).toBe("t3code/apps/web/src/session-logic.ts:501"); }); + it("keeps absolute windows paths outside the workspace unchanged", () => { + expect( + formatWorkspaceRelativePath( + "C:/Users/mike/dev-stuff/other-project/src/main.ts", + "C:/Users/mike/dev-stuff/t3code", + ), + ).toBe("C:/Users/mike/dev-stuff/other-project/src/main.ts"); + }); + it("keeps paths already rooted at the workspace label stable", () => { expect( formatWorkspaceRelativePath( diff --git a/apps/web/src/filePathDisplay.ts b/apps/web/src/filePathDisplay.ts index 5a6e2a02e100..0d785fb6f08e 100644 --- a/apps/web/src/filePathDisplay.ts +++ b/apps/web/src/filePathDisplay.ts @@ -21,6 +21,10 @@ function stripRelativePrefixes(path: string): string { return path.replace(/^\.\/+/, "").replace(/^\/+/, ""); } +function isAbsolutePath(path: string): boolean { + return path.startsWith("/") || /^[A-Za-z]:\//.test(path); +} + export function formatWorkspaceRelativePath( pathWithPosition: string, workspaceRoot: string | undefined, @@ -44,7 +48,7 @@ export function formatWorkspaceRelativePath( } else if (pathForCompare.startsWith(workspaceWithSeparator)) { const relativeSuffix = normalizedPath.slice(normalizedWorkspaceRoot.length + 1); displayPath = `${workspaceLabel}/${relativeSuffix}`; - } else if (!normalizedPath.startsWith("/")) { + } else if (!isAbsolutePath(normalizedPath)) { const relativePath = stripRelativePrefixes(normalizedPath); displayPath = pathForCompare.startsWith(workspaceLabelWithSeparator) ? normalizedPath diff --git a/apps/web/src/markdown-links-rendering.test.tsx b/apps/web/src/markdown-links-rendering.test.tsx new file mode 100644 index 000000000000..1ebf4d2e7a55 --- /dev/null +++ b/apps/web/src/markdown-links-rendering.test.tsx @@ -0,0 +1,40 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; +import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; +import { describe, expect, it } from "vite-plus/test"; + +import { remarkRewriteWindowsFileLinks, rewriteMarkdownFileUriHref } from "./markdown-links"; + +const sanitizeSchema = { + ...defaultSchema, + protocols: { + ...defaultSchema.protocols, + href: [...(defaultSchema.protocols?.href ?? []), "file"], + }, +} satisfies Parameters[0]; + +function renderMarkdown(markdown: string): string { + return renderToStaticMarkup( + rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href)} + > + {markdown} + , + ); +} + +describe("Windows markdown file link rendering", () => { + it("preserves a drive-path href through HTML sanitization", () => { + const path = "C:/Users/mike/dev-stuff/t3code/apps/web/src/markdown-links.ts"; + + expect(renderMarkdown(`[markdown-links.ts](${path}) is open above.`)).toContain( + `markdown-links.ts`, + ); + }); + + it("still removes unsafe schemes", () => { + expect(renderMarkdown("[unsafe](javascript:alert(1))")).not.toContain("href="); + }); +}); diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 9fc296138672..2de93e2bc8e4 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -36,6 +36,14 @@ describe("rewriteMarkdownFileUriHref", () => { }); describe("resolveMarkdownFileLinkTarget", () => { + it("resolves windows drive paths", () => { + expect( + resolveMarkdownFileLinkTarget( + "C:/Users/mike/dev-stuff/t3code/apps/web/src/markdown-links.ts", + ), + ).toBe("C:/Users/mike/dev-stuff/t3code/apps/web/src/markdown-links.ts"); + }); + it("resolves absolute posix file paths", () => { expect(resolveMarkdownFileLinkTarget("/Users/julius/project/AGENTS.md")).toBe( "/Users/julius/project/AGENTS.md", diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index a6dba941b8ac..178e40f745e9 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -108,6 +108,34 @@ export function rewriteMarkdownFileUriHref(href: string | undefined): string | n return `${target.path}${target.hash}`; } +interface MarkdownLinkNode { + type?: string; + url?: unknown; + children?: MarkdownLinkNode[]; +} + +/** + * rehype-sanitize reads the drive letter in `C:/path` as a URL scheme and + * removes the href before react-markdown's URL transform can normalize it. + * Convert only Windows drive-path destinations into the already-allowed file + * URI form while the document is still mdast. + */ +export function remarkRewriteWindowsFileLinks() { + return (tree: MarkdownLinkNode) => { + const visit = (node: MarkdownLinkNode) => { + if ((node.type === "link" || node.type === "definition") && typeof node.url === "string") { + const normalizedUrl = normalizeMarkdownLinkDestination(node.url); + if (WINDOWS_DRIVE_PATH_PATTERN.test(normalizedUrl)) { + node.url = `file:///${normalizedUrl.replaceAll("\\", "/")}`; + } + } + node.children?.forEach(visit); + }; + + visit(tree); + }; +} + function looksLikePosixFilesystemPath(path: string): boolean { if (!path.startsWith("/")) return false; if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true; From fd31c7f1160ec265749e2686767e2650783a4b8b Mon Sep 17 00:00:00 2001 From: "Arif Rahman (Bolt)" <32804415+peculiarnewbie@users.noreply.github.com> Date: Wed, 12 Aug 2026 06:52:46 +0700 Subject: [PATCH 2/3] fix(web): normalize Windows link separators --- apps/web/src/markdown-links-rendering.test.tsx | 8 ++++++++ apps/web/src/markdown-links.test.ts | 11 +++++++++++ apps/web/src/markdown-links.ts | 7 +++++-- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/web/src/markdown-links-rendering.test.tsx b/apps/web/src/markdown-links-rendering.test.tsx index 1ebf4d2e7a55..5deeaaa7e0f0 100644 --- a/apps/web/src/markdown-links-rendering.test.tsx +++ b/apps/web/src/markdown-links-rendering.test.tsx @@ -34,6 +34,14 @@ describe("Windows markdown file link rendering", () => { ); }); + it("canonicalizes a backslash drive-path href through HTML sanitization", () => { + const path = "C:\\Users\\mike\\dev-stuff\\t3code\\apps\\web\\src\\markdown-links.ts"; + + expect(renderMarkdown(`[markdown-links.ts](${path}) is open above.`)).toContain( + 'markdown-links.ts', + ); + }); + it("still removes unsafe schemes", () => { expect(renderMarkdown("[unsafe](javascript:alert(1))")).not.toContain("href="); }); diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 2de93e2bc8e4..a793151a079f 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -1,12 +1,23 @@ import { describe, expect, it } from "vite-plus/test"; import { + normalizeMarkdownLinkDestination, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref, } from "./markdown-links"; +describe("normalizeMarkdownLinkDestination", () => { + it("canonicalizes backslashes in windows drive paths", () => { + expect( + normalizeMarkdownLinkDestination( + "C:\\Users\\mike\\dev-stuff\\t3code\\apps\\web\\src\\markdown-links.ts", + ), + ).toBe("C:/Users/mike/dev-stuff/t3code/apps/web/src/markdown-links.ts"); + }); +}); + describe("rewriteMarkdownFileUriHref", () => { it("rewrites file uri hrefs into direct path hrefs", () => { expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/src/main.ts#L42")).toBe( diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 178e40f745e9..ef0a270a6189 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -61,7 +61,10 @@ function unwrapMarkdownLinkDestination(value: string): string { } export function normalizeMarkdownLinkDestination(value: string): string { - return unwrapMarkdownLinkDestination(value.trim()); + const normalizedValue = unwrapMarkdownLinkDestination(value.trim()); + return WINDOWS_DRIVE_PATH_PATTERN.test(normalizedValue) + ? normalizedValue.replaceAll("\\", "/") + : normalizedValue; } function stripSearchAndHash(value: string): { path: string; hash: string } { @@ -126,7 +129,7 @@ export function remarkRewriteWindowsFileLinks() { if ((node.type === "link" || node.type === "definition") && typeof node.url === "string") { const normalizedUrl = normalizeMarkdownLinkDestination(node.url); if (WINDOWS_DRIVE_PATH_PATTERN.test(normalizedUrl)) { - node.url = `file:///${normalizedUrl.replaceAll("\\", "/")}`; + node.url = `file:///${normalizedUrl}`; } } node.children?.forEach(visit); From 2e0324411aac84b98ba5cab41cc2a9100d2298df Mon Sep 17 00:00:00 2001 From: "Arif Rahman (Bolt)" <32804415+peculiarnewbie@users.noreply.github.com> Date: Wed, 12 Aug 2026 07:20:03 +0700 Subject: [PATCH 3/3] fix(web): normalize encoded Windows link keys --- apps/web/src/components/ChatMarkdown.tsx | 11 +++------- .../web/src/markdown-links-rendering.test.tsx | 8 +++++++ apps/web/src/markdown-links.test.ts | 22 +++++++++++++++++++ apps/web/src/markdown-links.ts | 9 ++++++++ 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index fcdd8eecb3a0..3bc55726c831 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -74,7 +74,7 @@ import { } from "../markdown-clipboard"; import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { - normalizeMarkdownLinkDestination, + normalizeMarkdownFileLinkHrefKey, remarkRewriteWindowsFileLinks, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, @@ -887,11 +887,6 @@ function extractMarkdownLinkHrefs(text: string): string[] { return hrefs; } -function normalizeMarkdownLinkHrefKey(href: string): string { - const normalizedHref = normalizeMarkdownLinkDestination(href); - return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; -} - const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; /** Hosts whose favicon request already failed this session — skip straight to the globe. */ @@ -1352,7 +1347,7 @@ function ChatMarkdown({ NonNullable> >(); for (const href of extractMarkdownLinkHrefs(text)) { - const normalizedHref = normalizeMarkdownLinkHrefKey(href); + const normalizedHref = normalizeMarkdownFileLinkHrefKey(href); if (metaByHref.has(normalizedHref)) continue; const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd); if (meta) { @@ -1546,7 +1541,7 @@ function ChatMarkdown({ ); }, a({ node, href, children, ...props }) { - const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; + const normalizedHref = href ? normalizeMarkdownFileLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; if (!fileLinkMeta) { const faviconHost = resolveExternalWebLinkHost(href); diff --git a/apps/web/src/markdown-links-rendering.test.tsx b/apps/web/src/markdown-links-rendering.test.tsx index 5deeaaa7e0f0..be7c6f65d5ee 100644 --- a/apps/web/src/markdown-links-rendering.test.tsx +++ b/apps/web/src/markdown-links-rendering.test.tsx @@ -42,6 +42,14 @@ describe("Windows markdown file link rendering", () => { ); }); + it("percent-encodes a unicode drive-path href through HTML sanitization", () => { + const path = "C:/Users/mike/dev-stuff/文档/apps/web/src/markdown-links.ts"; + + expect(renderMarkdown(`[markdown-links.ts](${path}) is open above.`)).toContain( + 'markdown-links.ts', + ); + }); + it("still removes unsafe schemes", () => { expect(renderMarkdown("[unsafe](javascript:alert(1))")).not.toContain("href="); }); diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index a793151a079f..8d6c19991fa4 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + normalizeMarkdownFileLinkHrefKey, normalizeMarkdownLinkDestination, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, @@ -18,6 +19,27 @@ describe("normalizeMarkdownLinkDestination", () => { }); }); +describe("normalizeMarkdownFileLinkHrefKey", () => { + it("uses the same key for raw and encoded unicode drive paths", () => { + const encodedPath = "C:/Users/mike/dev-stuff/%E6%96%87%E6%A1%A3/apps/web/src/markdown-links.ts"; + + expect( + normalizeMarkdownFileLinkHrefKey( + "C:\\Users\\mike\\dev-stuff\\文档\\apps\\web\\src\\markdown-links.ts", + ), + ).toBe(encodedPath); + expect(normalizeMarkdownFileLinkHrefKey(encodedPath)).toBe(encodedPath); + }); + + it("preserves existing encoded octets", () => { + expect( + normalizeMarkdownFileLinkHrefKey( + "C:/Users/mike/dev-stuff/t3code/apps/web/src/file%2520name.ts", + ), + ).toBe("C:/Users/mike/dev-stuff/t3code/apps/web/src/file%2520name.ts"); + }); +}); + describe("rewriteMarkdownFileUriHref", () => { it("rewrites file uri hrefs into direct path hrefs", () => { expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/src/main.ts#L42")).toBe( diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index ef0a270a6189..f6bab1474b9c 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -111,6 +111,15 @@ export function rewriteMarkdownFileUriHref(href: string | undefined): string | n return `${target.path}${target.hash}`; } +export function normalizeMarkdownFileLinkHrefKey(href: string): string { + const normalizedHref = normalizeMarkdownLinkDestination(href); + const rewrittenHref = rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; + if (!WINDOWS_DRIVE_PATH_PATTERN.test(rewrittenHref)) return rewrittenHref; + + const target = parseFileUrlHref(`file:///${rewrittenHref}`, { decodePath: false }); + return target ? `${target.path}${target.hash}` : rewrittenHref; +} + interface MarkdownLinkNode { type?: string; url?: unknown;