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";
33const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
44const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
55const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
6const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/;
7const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
6const RELATIVE_FILE_PATH_PATTERN =
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}$/;
810const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
911const POSIX_FILE_ROOT_PREFIXES = [
1012 "/Users/",
apps/mobile/src/lib/markdownLinks.test.ts+18
......@@ -50,6 +50,24 @@ describe("resolveMarkdownLinkPresentation", () => {
5050 });
5151 });
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
5371 it("extracts line fragments from relative file links", () => {
5472 expect(resolveMarkdownLinkPresentation("src/main.ts#L18C2")).toMatchObject({
5573 kind: "file",
apps/web/src/components/ChatMarkdown.tsx+2-1
......@@ -80,6 +80,7 @@ import {
8080 resolveInlineCodeFileLinkMeta,
8181 resolveMarkdownFileLinkMeta,
8282 rewriteMarkdownFileUriHref,
83 shouldOpenMarkdownFileLinkInBrowserByDefault,
8384 shouldOpenMarkdownFileLinkInEditor,
8485 type MarkdownFileLinkMeta,
8586} from "../markdown-links";
......@@ -1402,7 +1403,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
14021403 handleOpenInEditor();
14031404 return;
14041405 }
1405 if (onOpenInBrowser) {
1406 if (onOpenInBrowser && shouldOpenMarkdownFileLinkInBrowserByDefault(iconPath)) {
14061407 handleOpenInBrowser();
14071408 return;
14081409 }
apps/web/src/markdown-links.test.ts+24
......@@ -9,6 +9,7 @@ import {
99 resolveMarkdownFileLinkMeta,
1010 resolveMarkdownFileLinkTarget,
1111 rewriteMarkdownFileUriHref,
12 shouldOpenMarkdownFileLinkInBrowserByDefault,
1213 shouldOpenMarkdownFileLinkInEditor,
1314} from "./markdown-links";
1415
......@@ -69,6 +70,15 @@ describe("shouldOpenMarkdownFileLinkInEditor", () => {
6970 });
7071});
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
7282describe("rewriteMarkdownFileUriHref", () => {
7383 it("rewrites file uri hrefs into direct path hrefs", () => {
7484 expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/src/main.ts#L42")).toBe(
......@@ -198,6 +208,20 @@ describe("resolveMarkdownFileLinkTarget", () => {
198208 });
199209 });
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
201225 it("formats tooltip display paths relative to the cwd for slash-prefixed windows paths", () => {
202226 expect(
203227 resolveMarkdownFileLinkMeta(
apps/web/src/markdown-links.ts+6-1
......@@ -11,7 +11,8 @@ const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/;
1111const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
1212const RELATIVE_FILE_PATH_PATTERN =
1313 /^(?:[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}$/;
1516const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
1617const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/;
1718// Standard OS and dev-container roots; deliberately excludes app-route-ish
......@@ -71,6 +72,10 @@ export function shouldOpenMarkdownFileLinkInEditor(
7172 return isTerminalLinkActivation(event, platform);
7273}
7374
75export function shouldOpenMarkdownFileLinkInBrowserByDefault(path: string): boolean {
76 return /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? "");
77}
78
7479function safeDecode(value: string): string {
7580 try {
7681 return decodeURIComponent(value);