authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-24 23:51:40+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 17:51:40-04:00
log99960383d094b850f0001d3067fa604aa3adf7d6
tree6c6ad044a442b2800dda5666b8a4ec4ad0037d1b
parenta1379db81847cc70b2c9535fd8370b69f9ebf291
signature Signed by PGP key B5690EEEBB952194

fix: open agent file links in the file viewer (#8098)


5 files changed, 54 insertions(+), 4 deletions(-)

apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts+4-2
...@@ -3,8 +3,10 @@ import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";...@@ -3,8 +3,10 @@ import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
3const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;3const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
4const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;4const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
5const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;5const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
6const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/;6const RELATIVE_FILE_PATH_PATTERN =
7const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;7 /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/;
8const RELATIVE_FILE_NAME_PATTERN =
9 /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
8const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;10const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
9const POSIX_FILE_ROOT_PREFIXES = [11const POSIX_FILE_ROOT_PREFIXES = [
10 "/Users/",12 "/Users/",
apps/mobile/src/lib/markdownLinks.test.ts+18
...@@ -50,6 +50,24 @@ describe("resolveMarkdownLinkPresentation", () => {...@@ -50,6 +50,24 @@ describe("resolveMarkdownLinkPresentation", () => {
50 });50 });
51 });51 });
5252
53 it.each(["md", "html", "xml"])("recognizes a bare spaced .%s filename", (extension) => {
54 expect(
55 resolveMarkdownLinkPresentation(`Updated%20cutover%20checklist.${extension}`),
56 ).toMatchObject({
57 kind: "file",
58 path: `Updated cutover checklist.${extension}`,
59 label: `Updated cutover checklist.${extension}`,
60 });
61 });
62
63 it("recognizes spaced relative paths", () => {
64 expect(resolveMarkdownLinkPresentation("docs/My%20Folder/checklist.xml")).toMatchObject({
65 kind: "file",
66 path: "docs/My Folder/checklist.xml",
67 label: "checklist.xml",
68 });
69 });
70
53 it("extracts line fragments from relative file links", () => {71 it("extracts line fragments from relative file links", () => {
54 expect(resolveMarkdownLinkPresentation("src/main.ts#L18C2")).toMatchObject({72 expect(resolveMarkdownLinkPresentation("src/main.ts#L18C2")).toMatchObject({
55 kind: "file",73 kind: "file",
apps/web/src/components/ChatMarkdown.tsx+2-1
...@@ -80,6 +80,7 @@ import {...@@ -80,6 +80,7 @@ import {
80 resolveInlineCodeFileLinkMeta,80 resolveInlineCodeFileLinkMeta,
81 resolveMarkdownFileLinkMeta,81 resolveMarkdownFileLinkMeta,
82 rewriteMarkdownFileUriHref,82 rewriteMarkdownFileUriHref,
83 shouldOpenMarkdownFileLinkInBrowserByDefault,
83 shouldOpenMarkdownFileLinkInEditor,84 shouldOpenMarkdownFileLinkInEditor,
84 type MarkdownFileLinkMeta,85 type MarkdownFileLinkMeta,
85} from "../markdown-links";86} from "../markdown-links";
...@@ -1402,7 +1403,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1402,7 +1403,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1402 handleOpenInEditor();1403 handleOpenInEditor();
1403 return;1404 return;
1404 }1405 }
1405 if (onOpenInBrowser) {1406 if (onOpenInBrowser && shouldOpenMarkdownFileLinkInBrowserByDefault(iconPath)) {
1406 handleOpenInBrowser();1407 handleOpenInBrowser();
1407 return;1408 return;
1408 }1409 }
apps/web/src/markdown-links.test.ts+24
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9 resolveMarkdownFileLinkMeta,9 resolveMarkdownFileLinkMeta,
10 resolveMarkdownFileLinkTarget,10 resolveMarkdownFileLinkTarget,
11 rewriteMarkdownFileUriHref,11 rewriteMarkdownFileUriHref,
12 shouldOpenMarkdownFileLinkInBrowserByDefault,
12 shouldOpenMarkdownFileLinkInEditor,13 shouldOpenMarkdownFileLinkInEditor,
13} from "./markdown-links";14} from "./markdown-links";
1415
...@@ -69,6 +70,15 @@ describe("shouldOpenMarkdownFileLinkInEditor", () => {...@@ -69,6 +70,15 @@ describe("shouldOpenMarkdownFileLinkInEditor", () => {
69 });70 });
70});71});
7172
73describe("shouldOpenMarkdownFileLinkInBrowserByDefault", () => {
74 it("keeps PDFs browser-first while source files open in the file viewer", () => {
75 expect(shouldOpenMarkdownFileLinkInBrowserByDefault("report.pdf")).toBe(true);
76 expect(shouldOpenMarkdownFileLinkInBrowserByDefault("report.PDF?download=1")).toBe(true);
77 expect(shouldOpenMarkdownFileLinkInBrowserByDefault("report.html")).toBe(false);
78 expect(shouldOpenMarkdownFileLinkInBrowserByDefault("report.xml")).toBe(false);
79 });
80});
81
72describe("rewriteMarkdownFileUriHref", () => {82describe("rewriteMarkdownFileUriHref", () => {
73 it("rewrites file uri hrefs into direct path hrefs", () => {83 it("rewrites file uri hrefs into direct path hrefs", () => {
74 expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/src/main.ts#L42")).toBe(84 expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/src/main.ts#L42")).toBe(
...@@ -198,6 +208,20 @@ describe("resolveMarkdownFileLinkTarget", () => {...@@ -198,6 +208,20 @@ describe("resolveMarkdownFileLinkTarget", () => {
198 });208 });
199 });209 });
200210
211 it.each(["md", "html", "xml"])(
212 "resolves a bare spaced .%s filename from the markdown renderer",
213 (extension) => {
214 const href = renderMarkdownLinkHref(`[checklist](<Updated cutover checklist.${extension}>)`);
215
216 expect(href).toBe(`Updated%20cutover%20checklist.${extension}`);
217 expect(resolveMarkdownFileLinkMeta(href, "/repo/project")).toMatchObject({
218 targetPath: `/repo/project/Updated cutover checklist.${extension}`,
219 workspaceRelativePath: `Updated cutover checklist.${extension}`,
220 basename: `Updated cutover checklist.${extension}`,
221 });
222 },
223 );
224
201 it("formats tooltip display paths relative to the cwd for slash-prefixed windows paths", () => {225 it("formats tooltip display paths relative to the cwd for slash-prefixed windows paths", () => {
202 expect(226 expect(
203 resolveMarkdownFileLinkMeta(227 resolveMarkdownFileLinkMeta(
apps/web/src/markdown-links.ts+6-1
...@@ -11,7 +11,8 @@ const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/;...@@ -11,7 +11,8 @@ const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/;
11const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;11const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
12const RELATIVE_FILE_PATH_PATTERN =12const RELATIVE_FILE_PATH_PATTERN =
13 /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/;13 /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/;
14const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;14const RELATIVE_FILE_NAME_PATTERN =
15 /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
15const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;16const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
16const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/;17const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/;
17// Standard OS and dev-container roots; deliberately excludes app-route-ish18// Standard OS and dev-container roots; deliberately excludes app-route-ish
...@@ -71,6 +72,10 @@ export function shouldOpenMarkdownFileLinkInEditor(...@@ -71,6 +72,10 @@ export function shouldOpenMarkdownFileLinkInEditor(
71 return isTerminalLinkActivation(event, platform);72 return isTerminalLinkActivation(event, platform);
72}73}
7374
75export function shouldOpenMarkdownFileLinkInBrowserByDefault(path: string): boolean {
76 return /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? "");
77}
78
74function safeDecode(value: string): string {79function safeDecode(value: string): string {
75 try {80 try {
76 return decodeURIComponent(value);81 return decodeURIComponent(value);