From 38e3217be34cb22a7cecd0d5f8f81418e4dda087 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 3 Nov 2026 18:13:08 +0000 Subject: [PATCH 1/5] test(web): reproduce #30285, PR markdown chips inline code paths outside the repo Co-Authored-By: Claude Opus 5.5 --- .../PullRequestMarkdown.browser.test.tsx | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx index 6bbfcdadd54a..94dd63ec7bfd 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx @@ -5,7 +5,10 @@ import { expect, it } from "vite-plus/test"; import { render } from "../../../test/render"; import { PullRequestMarkdown, PullRequestMarkdownContext } from "./PullRequestMarkdown"; -async function renderBody(host: string, options: { lineBreaks?: boolean } = {}) { +async function renderBody( + host: string, + options: { lineBreaks?: boolean; text?: string; cwd?: string } = {}, +) { const context = { remarkPlugins: [], lineBreaks: sourceControlClients.get(host).hardLineBreaks ?? false, @@ -43,3 +46,19 @@ it("lets text the host did not write opt out of the host's breaks", async () => await expect.element(screen.getByText(/first line/)).toBeInTheDocument(); expect(screen.container.querySelectorAll("p br")).toHaveLength(0); }); + +it("chips only inline code paths inside the repository", async () => { + await using screen = await renderBody("github", { + cwd: "/home/alice/repo", + text: "Typing `~/Dév` hides it under `~/`. See `/etc/hosts`, `../outside.ts` and `packages/x/y.ts`.", + }); + await expect + .element(screen.getByRole("button", { name: "File options for y.ts" })) + .toBeInTheDocument(); + const chips = [...screen.container.querySelectorAll(".chat-markdown-file-link")]; + expect(chips.map((chip) => chip.getAttribute("data-markdown-copy"))).toEqual([ + "`packages/x/y.ts`", + ]); + const code = [...screen.container.querySelectorAll("code")].map((c) => c.textContent); + expect(code).toEqual(["~/Dév", "~/", "/etc/hosts", "../outside.ts"]); +}); From db20830bd0b54f83b8610add9719b26850256817 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 3 Nov 2026 18:18:31 +0000 Subject: [PATCH 2/5] fix(web): PR markdown chips only files inside the repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull request text is written on the author's machine, but inline code and links that looked like paths (`~/…`, `/etc/hosts`, `../x`) became file chips resolved against the viewer's home with open and reveal actions. PR content now chips only paths inside the project; other inline code renders as plain code and other local-path links render as their label. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatMarkdown.tsx | 68 +++++++++++++------ .../PullRequestMarkdown.browser.test.tsx | 16 +++++ .../pullRequest/PullRequestMarkdown.tsx | 1 + 3 files changed, 64 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index dd37be3b4b6a..03801d5b8513 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -247,6 +247,9 @@ interface ChatMarkdownProps { /** Loads media a source control host keeps behind a credential through the server, which holds `cwd`'s; without it a private repository's uploads load unauthenticated and 404. */ sourceControlMedia?: boolean | undefined; + /** Text written on another machine (a pull request) names paths there; only files inside `cwd` + become file links, and other path links render as their label. */ + workspaceFileLinksOnly?: boolean | undefined; headingLevelOffset?: number | undefined; } @@ -448,35 +451,51 @@ const markdownServerConfigFieldsAtom = Atom.family((environmentId: EnvironmentId }).pipe(Atom.withLabel("markdown-server-config-fields")), ); -function fileLinkSourcesKey( - text: string, - cwd: string | undefined, - imageBaseDir: string | undefined, -): string { +interface FileLinkScope { + cwd: string | undefined; + imageBaseDir: string | undefined; + workspaceFileLinksOnly: boolean; +} + +function keepFileLink(meta: MarkdownFileLinkMeta | null, scope: FileLinkScope) { + return meta && (!scope.workspaceFileLinksOnly || meta.workspaceRelativePath !== null) + ? meta + : null; +} + +function markdownFileLinkMeta(href: string, scope: FileLinkScope) { + return keepFileLink( + resolveMarkdownFileLinkMeta(href, scope.cwd, scope.imageBaseDir ?? scope.cwd), + scope, + ); +} + +function inlineCodeFileLinkMeta(codeText: string, scope: FileLinkScope) { + return keepFileLink( + resolveInlineCodeFileLinkMeta(codeText, scope.cwd, scope.imageBaseDir ?? scope.cwd), + scope, + ); +} + +function fileLinkSourcesKey(text: string, scope: FileLinkScope): string { const hrefs = extractMarkdownLinkHrefs(renderCodexInlineDirectivesAsMarkdown(text)) .filter((href) => !parseComposerContextHref(href)) .map(normalizeMarkdownLinkHrefKey) - .filter((href) => resolveMarkdownFileLinkMeta(href, cwd, imageBaseDir ?? cwd)); - const spans = extractInlineCodeSpans(text).filter((span) => - resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd), - ); + .filter((href) => markdownFileLinkMeta(href, scope)); + const spans = extractInlineCodeSpans(text).filter((span) => inlineCodeFileLinkMeta(span, scope)); return JSON.stringify([[...new Set(hrefs)], [...new Set(spans)]]); } -function resolveFileLinkSources( - key: string, - cwd: string | undefined, - imageBaseDir: string | undefined, -) { +function resolveFileLinkSources(key: string, scope: FileLinkScope) { const [hrefs, spans] = JSON.parse(key) as [string[], string[]]; const markdownFileLinkMetaByHref = new Map(); for (const href of hrefs) { - const meta = resolveMarkdownFileLinkMeta(href, cwd, imageBaseDir ?? cwd); + const meta = markdownFileLinkMeta(href, scope); if (meta) markdownFileLinkMetaByHref.set(href, meta); } const inlineCodeFileLinkMetaByText = new Map(); for (const span of spans) { - const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd); + const meta = inlineCodeFileLinkMeta(span, scope); if (meta) inlineCodeFileLinkMetaByText.set(span, meta); } const fileLinkParentSuffixByPath = buildFileLinkParentSuffixByPath([ @@ -504,6 +523,7 @@ function useChatMarkdownState({ renderContextReference, headingLevelOffset = 0, sourceControlMedia = false, + workspaceFileLinksOnly = false, }: ChatMarkdownProps) { const resolvedTheme = useRenderedAppearance(); const markdownRef = useRef(null); @@ -680,9 +700,10 @@ function useChatMarkdownState({ // Keyed by the file links found, not the text, so streamed text that adds none keeps the // renderer context, and every block reading it, unchanged. // A template literal marks the key as a string, so the compiler memoizes the maps on its value. - const fileLinksKey = `${fileLinkSourcesKey(text, cwd, imageBaseDir)}`; + const fileLinkScope = { cwd, imageBaseDir, workspaceFileLinksOnly }; + const fileLinksKey = `${fileLinkSourcesKey(text, fileLinkScope)}`; const { markdownFileLinkMetaByHref, inlineCodeFileLinkMetaByText, fileLinkParentSuffixByPath } = - resolveFileLinkSources(fileLinksKey, cwd, imageBaseDir); + resolveFileLinkSources(fileLinksKey, fileLinkScope); const resolveThreadPullRequest = ( href: string, ): (ThreadPullRequestKey & { readonly url: string }) | null => { @@ -900,6 +921,7 @@ function useChatMarkdownState({ environmentId, expandMedia, fileLinkChip, + fileLinkScope, sourceControlMedia, renderContextReference, headingLevelOffset, @@ -1078,6 +1100,7 @@ const CHAT_MARKDOWN_COMPONENTS = { pullRequestsEnabled, updateThreadPullRequestLink, fileLinkChip, + fileLinkScope, renderContextReference, } = use(ChatMarkdownRendererContext); const text = use(ChatMarkdownTextContext); @@ -1108,6 +1131,8 @@ const CHAT_MARKDOWN_COMPONENTS = { ? (markdownFileLinkMetaByHref.get(normalizedHref) ?? resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd)) : null; + // As a web link a local path would still open on this machine, so it keeps only its label. + if (fileLinkMeta && !keepFileLink(fileLinkMeta, fileLinkScope)) return {children}; if (!fileLinkMeta) { const faviconHost = resolveExternalWebLinkHost(href); const hasText = hastHasText(node); @@ -1333,8 +1358,9 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, code: function MarkdownCode({ node, children, className, ...props }) { - const { cwd, imageBaseDir, inlineCodeFileLinkMetaByText, fileLinkChip, mathEnabled, inline } = - use(ChatMarkdownRendererContext); + const { fileLinkScope, inlineCodeFileLinkMetaByText, fileLinkChip, mathEnabled, inline } = use( + ChatMarkdownRendererContext, + ); const math = mathEnabled ? mathKind(className) : null; if (math) { return ( @@ -1349,7 +1375,7 @@ const CHAT_MARKDOWN_COMPONENTS = { const codeText = nodeToPlainText(children); const fileLinkMeta = inlineCodeFileLinkMetaByText.get(codeText.trim()) ?? - resolveInlineCodeFileLinkMeta(codeText, cwd, imageBaseDir ?? cwd); + inlineCodeFileLinkMeta(codeText, fileLinkScope); if (fileLinkMeta) { return fileLinkChip( fileLinkMeta, diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx index 94dd63ec7bfd..df86edfb6a6c 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx @@ -62,3 +62,19 @@ it("chips only inline code paths inside the repository", async () => { const code = [...screen.container.querySelectorAll("code")].map((c) => c.textContent); expect(code).toEqual(["~/Dév", "~/", "/etc/hosts", "../outside.ts"]); }); + +it("chips only links to files inside the repository", async () => { + await using screen = await renderBody("github", { + cwd: "/home/alice/repo", + text: "See [hosts](/etc/hosts), [key](~/.ssh/id_rsa), [up](file:///etc/hosts) and [y](packages/x/y.ts).", + }); + await expect + .element(screen.getByRole("button", { name: "File options for y.ts" })) + .toBeInTheDocument(); + const chips = [...screen.container.querySelectorAll(".chat-markdown-file-link")]; + expect(chips.map((chip) => chip.getAttribute("data-markdown-copy"))).toEqual([ + "[y](packages/x/y.ts)", + ]); + expect(screen.container.querySelectorAll("a")).toHaveLength(0); + expect(screen.container.textContent).toContain("See hosts, key, up and"); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index 566d34910d44..d44ea6361b28 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -92,6 +92,7 @@ export function PullRequestMarkdown({ {...(context ? { extraRemarkPlugins: context.remarkPlugins } : {})} lineBreaks={lineBreaks ?? context?.lineBreaks ?? false} sourceControlMedia + workspaceFileLinksOnly /> ); } From 02225422365e814e1a4caa9d2f78d25210b5fac7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 3 Nov 2026 18:29:08 +0000 Subject: [PATCH 3/5] fix(web): keep file-link memo stable and gate image chips in PR markdown Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatMarkdown.tsx | 4 +++- .../pullRequest/PullRequestMarkdown.browser.test.tsx | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 03801d5b8513..1b31d952d7d7 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -700,8 +700,8 @@ function useChatMarkdownState({ // Keyed by the file links found, not the text, so streamed text that adds none keeps the // renderer context, and every block reading it, unchanged. // A template literal marks the key as a string, so the compiler memoizes the maps on its value. + const fileLinksKey = `${fileLinkSourcesKey(text, { cwd, imageBaseDir, workspaceFileLinksOnly })}`; const fileLinkScope = { cwd, imageBaseDir, workspaceFileLinksOnly }; - const fileLinksKey = `${fileLinkSourcesKey(text, fileLinkScope)}`; const { markdownFileLinkMetaByHref, inlineCodeFileLinkMetaByText, fileLinkParentSuffixByPath } = resolveFileLinkSources(fileLinksKey, fileLinkScope); const resolveThreadPullRequest = ( @@ -1406,6 +1406,7 @@ const CHAT_MARKDOWN_COMPONENTS = { workspaceMutationId, renderContextReference, fileLinkChip, + fileLinkScope, resolvedTheme, } = use(ChatMarkdownRendererContext); const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; @@ -1468,6 +1469,7 @@ const CHAT_MARKDOWN_COMPONENTS = { } if (mediaKind === null && imageSource._tag === "WorkspaceFile") { const fileLinkMeta = resolveMarkdownFileLinkMeta(srcString, cwd, imageBaseDir ?? cwd); + if (fileLinkMeta && !keepFileLink(fileLinkMeta, fileLinkScope)) return {altText}; if (fileLinkMeta) return fileLinkChip(fileLinkMeta, copyMarkdown, srcString); } const kind = mediaKind ?? "image"; diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx index df86edfb6a6c..6ab3401366e3 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx @@ -63,10 +63,10 @@ it("chips only inline code paths inside the repository", async () => { expect(code).toEqual(["~/Dév", "~/", "/etc/hosts", "../outside.ts"]); }); -it("chips only links to files inside the repository", async () => { +it("chips only links and images to files inside the repository", async () => { await using screen = await renderBody("github", { cwd: "/home/alice/repo", - text: "See [hosts](/etc/hosts), [key](~/.ssh/id_rsa), [up](file:///etc/hosts) and [y](packages/x/y.ts).", + text: "See [hosts](/etc/hosts), [key](~/.ssh/id_rsa), [up](file:///etc/hosts), ![rc](/home/alice/.bashrc) and [y](packages/x/y.ts).", }); await expect .element(screen.getByRole("button", { name: "File options for y.ts" })) @@ -76,5 +76,5 @@ it("chips only links to files inside the repository", async () => { "[y](packages/x/y.ts)", ]); expect(screen.container.querySelectorAll("a")).toHaveLength(0); - expect(screen.container.textContent).toContain("See hosts, key, up and"); + expect(screen.container.textContent).toContain("See hosts, key, up, rc and"); }); From ab1c43e2c7e2ed905acb9fa16ee8a027f0dd035f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 00:30:48 +0000 Subject: [PATCH 4/5] fix(web): home-relative images in PR text render as their label Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatMarkdown.tsx | 6 ++++-- .../pullRequest/PullRequestMarkdown.browser.test.tsx | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 92a2d2709813..59d4c013b42b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1476,10 +1476,12 @@ const CHAT_MARKDOWN_COMPONENTS = { /> ); } - if (mediaKind === null && imageSource._tag === "WorkspaceFile") { + if (mediaKind === null && imageSource._tag !== "Direct") { const fileLinkMeta = resolveMarkdownFileLinkMeta(srcString, cwd, imageBaseDir ?? cwd); if (fileLinkMeta && !keepFileLink(fileLinkMeta, fileLinkScope)) return {altText}; - if (fileLinkMeta) return fileLinkChip(fileLinkMeta, copyMarkdown, srcString); + if (fileLinkMeta && imageSource._tag === "WorkspaceFile") { + return fileLinkChip(fileLinkMeta, copyMarkdown, srcString); + } } const kind = mediaKind ?? "image"; const directUri = imageSource._tag === "Direct" ? imageSource.uri : null; diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx index 6d3c8d4b6a45..ab5ae121f728 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.browser.test.tsx @@ -81,7 +81,7 @@ it("chips only inline code paths inside the repository", async () => { it("chips only links and images to files inside the repository", async () => { await using screen = await renderBody("github", { cwd: "/home/alice/repo", - text: "See [hosts](/etc/hosts), [key](~/.ssh/id_rsa), [up](file:///etc/hosts), ![rc](/home/alice/.bashrc) and [y](packages/x/y.ts).", + text: "See [hosts](/etc/hosts), [key](~/.ssh/id_rsa), [up](file:///etc/hosts), ![rc](/home/alice/.bashrc), ![profile](~/.profile) and [y](packages/x/y.ts).", }); await expect .element(screen.getByRole("button", { name: "File options for y.ts" })) @@ -91,7 +91,7 @@ it("chips only links and images to files inside the repository", async () => { "[y](packages/x/y.ts)", ]); expect(screen.container.querySelectorAll("a")).toHaveLength(0); - expect(screen.container.textContent).toContain("See hosts, key, up, rc and"); + expect(screen.container.textContent).toContain("See hosts, key, up, rc, profile and"); }); it("loads the body's host media in the pull request's repository, not the checkout's", async () => { From b72ca7c04c2ba7964b99cbb2813ac1ba7dc73255 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 05:02:28 +0000 Subject: [PATCH 5/5] refactor(web): build the PR markdown file-link scope once AI-assisted; verified with the tests and tools listed above. --- apps/web/src/components/ChatMarkdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index d6d440cd00fb..ee9c7f5ecc7d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -699,8 +699,8 @@ function useChatMarkdownState({ // Keyed by the file links found, not the text, so streamed text that adds none keeps the // renderer context, and every block reading it, unchanged. // A template literal marks the key as a string, so the compiler memoizes the maps on its value. - const fileLinksKey = `${fileLinkSourcesKey(text, { cwd, imageBaseDir, workspaceFileLinksOnly })}`; const fileLinkScope = { cwd, imageBaseDir, workspaceFileLinksOnly }; + const fileLinksKey = `${fileLinkSourcesKey(text, fileLinkScope)}`; const { markdownFileLinkMetaByHref, inlineCodeFileLinkMetaByText, fileLinkParentSuffixByPath } = resolveFileLinkSources(fileLinksKey, fileLinkScope); const resolveThreadPullRequest = (