From 3ca6030dd0bed2b903d09321849b04cd3003bd48 Mon Sep 17 00:00:00 2001 From: Ethan <9874748+ethanblake4@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:08:08 -0700 Subject: [PATCH 1/6] fix(clients): recognize line ranges in file chips --- .../t3-markdown-text/src/markdownLinks.ts | 7 +- apps/mobile/src/lib/markdownLinks.test.ts | 9 +++ .../mobile/src/lib/nativeMarkdownText.test.ts | 32 +++++---- .../ChatMarkdown.permissions.test.tsx | 57 ++++++++++----- apps/web/src/components/ChatMarkdown.tsx | 14 +++- apps/web/src/markdown-links.test.ts | 69 ++++++++++++++++--- apps/web/src/markdown-links.ts | 4 +- .../client-runtime/src/markdownLinks.test.ts | 27 ++++++++ packages/client-runtime/src/markdownLinks.ts | 20 +++--- 9 files changed, 187 insertions(+), 52 deletions(-) diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts index 8b491c0c77c15..de6eafb800673 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -4,14 +4,13 @@ import { inlineCodeFilePathCandidate, normalizeMarkdownLinkDestination, parseMarkdownFileLink, + splitFilePathPosition, } from "@t3tools/client-runtime/markdown-links"; import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; import { videoMimeType } from "@t3tools/shared/video"; import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; -const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; - export type MarkdownLinkPresentation = | { readonly kind: "external"; @@ -26,6 +25,7 @@ export type MarkdownLinkPresentation = readonly path: string; readonly line?: number; readonly column?: number; + readonly endLine?: number; } | { readonly kind: "link"; @@ -249,7 +249,7 @@ export function normalizeNativeMarkdownUrl(value: string): string { } export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { - const basename = fileBasename(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); + const basename = fileBasename(splitFilePathPosition(value).path).toLowerCase(); if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video"; const exactIcon = FILE_ICON_BY_NAME[basename]; if (exactIcon) return exactIcon; @@ -289,6 +289,7 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese path: target.path, ...(target.line ? { line: target.line } : {}), ...(target.column ? { column: target.column } : {}), + ...(target.endLine ? { endLine: target.endLine } : {}), }; } diff --git a/apps/mobile/src/lib/markdownLinks.test.ts b/apps/mobile/src/lib/markdownLinks.test.ts index 38f1f5211421a..b786a0a486faf 100644 --- a/apps/mobile/src/lib/markdownLinks.test.ts +++ b/apps/mobile/src/lib/markdownLinks.test.ts @@ -57,6 +57,15 @@ describe("resolveMarkdownLinkPresentation", () => { }); it("recognizes relative source paths and bare filenames", () => { + expect(resolveMarkdownLinkPresentation("apps/mobile/src/index.ts:49-74")).toEqual({ + kind: "file", + href: "apps/mobile/src/index.ts:49-74", + icon: "typescript", + label: "index.ts:49-74", + path: "apps/mobile/src/index.ts", + line: 49, + endLine: 74, + }); expect(resolveMarkdownLinkPresentation("apps/mobile/src/index.ts:10")).toEqual({ kind: "file", href: "apps/mobile/src/index.ts:10", diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 62f0de0c1b955..9cc13965ef751 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -69,19 +69,27 @@ describe("nativeMarkdownTextRuns", () => { { start: 9, end: 10, text: "![Screenshot](t3-context://v1/image/screenshot)" }, ]); }); - it("links a path-shaped code span without changing the same path in prose", () => { + it.each([ + { path: "/tmp/frame.png", label: "frame.png", icon: "image" }, + { path: "src/main.ts:49-74", label: "main.ts:49-74", icon: "typescript" }, + ])("links $path in code without changing the same path in prose", ({ path, label, icon }) => { + const runs = nativeMarkdownTextRuns({ + type: "paragraph", + children: [ + { type: "text", content: `${path} ` }, + { type: "code_inline", content: path }, + ], + }); + expect(runs).toEqual([{ text: `${path} ` }, { text: label, href: path, fileIcon: icon }]); expect( - nativeMarkdownTextRuns({ - type: "paragraph", - children: [ - { type: "text", content: "/tmp/frame.png " }, - { type: "code_inline", content: "/tmp/frame.png" }, - ], - }), - ).toEqual([ - { text: "/tmp/frame.png " }, - { text: "frame.png", href: "/tmp/frame.png", fileIcon: "image" }, - ]); + nativeMarkdownContextCopyRanges( + runs.map((run) => ({ + run, + text: run.fileIcon ? "\uFFFC" : run.text, + inlineImageLength: 0, + })), + ), + ).toEqual([{ start: path.length + 1, end: path.length + 2, text: `[${label}](<${path}>)` }]); }); it("keeps a thread link's href so a press can open the thread", () => { diff --git a/apps/web/src/components/ChatMarkdown.permissions.test.tsx b/apps/web/src/components/ChatMarkdown.permissions.test.tsx index 750d013e0ceee..1215434a2ff32 100644 --- a/apps/web/src/components/ChatMarkdown.permissions.test.tsx +++ b/apps/web/src/components/ChatMarkdown.permissions.test.tsx @@ -188,22 +188,47 @@ function offeredActions() { return state.choose.mock.lastCall![0].map((item: { id: string }) => item.id); } -it("removes host actions after revocation while keeping file preview and copying", async () => { - await renderMarkdown("[Readme](docs/readme.md)"); - await openContextMenu(); - expect(offeredActions()).toEqual(["open", "reveal", "copy-relative", "copy-full"]); - await act(async () => { - state.allowed = false; - for (const listener of state.listeners) listener(); - }); - state.choose.mockResolvedValue("copy-full"); - await openContextMenu(); - expect(offeredActions()).toEqual(["copy-relative", "copy-full"]); - expect(state.copy).toHaveBeenCalledWith("/work/docs/readme.md"); - await act(async () => anchor().props.onClick(menuEvent())); - expect(state.openFile).toHaveBeenCalledWith(threadRef, "docs/readme.md", undefined); - expect(state.openEditor).not.toHaveBeenCalled(); -}); +it.each([ + { + text: "[Readme](docs/readme.md)", + path: "docs/readme.md", + position: "", + editorPosition: "", + line: undefined, + }, + { + text: "`docs/readme.md:49-74`", + path: "docs/readme.md", + position: ":49-74", + editorPosition: ":49", + line: 49, + }, +])( + "keeps $path preview and copying after host actions are revoked", + async ({ text, path, position, editorPosition, line }) => { + await renderMarkdown(text); + await openContextMenu(); + expect(offeredActions()).toEqual(["open", "reveal", "copy-relative", "copy-full"]); + state.choose.mockResolvedValue("open"); + await openContextMenu(); + expect(state.openEditor).toHaveBeenCalledExactlyOnceWith({ + environmentId: threadRef.environmentId, + input: { cwd: `/work/${path}${editorPosition}`, editor: "vscode" }, + }); + state.openEditor.mockClear(); + await act(async () => { + state.allowed = false; + for (const listener of state.listeners) listener(); + }); + state.choose.mockResolvedValue("copy-full"); + await openContextMenu(); + expect(offeredActions()).toEqual(["copy-relative", "copy-full"]); + expect(state.copy).toHaveBeenCalledWith(`/work/${path}${position}`); + await act(async () => anchor().props.onClick(menuEvent())); + expect(state.openFile).toHaveBeenCalledWith(threadRef, path, line); + expect(state.openEditor).not.toHaveBeenCalled(); + }, +); it("does not launch an editor after revocation during a native context menu", async () => { let choose: (action: string) => void = () => { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 42a34d8b5d25c..33b7234c25865 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -51,7 +51,11 @@ import { classifyMarkdownImageSource, markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; -import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links"; +import { + formatFilePathPosition, + inlineCodeFilePathCandidate, + splitFilePathPosition, +} from "@t3tools/client-runtime/markdown-links"; import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; @@ -2150,7 +2154,11 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ } void (async () => { try { - const result = await onOpen(targetPath); + // Editors accept a single line/column; keep ranges for display and copying. + const { endLine, ...position } = splitFilePathPosition(targetPath); + const result = await onOpen( + endLine === undefined ? targetPath : formatFilePathPosition(position), + ); if (result._tag === "Success" || isAtomCommandInterrupted(result)) { return; } @@ -2835,7 +2843,7 @@ function useChatMarkdownState({ } if (fileLinkMeta.line) { labelParts.push( - `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, + `L${fileLinkMeta.line}${fileLinkMeta.endLine ? `-${fileLinkMeta.endLine}` : fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, ); } const mediaPath = mediaSource ?? fileLinkMeta.filePath; diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index fe352ec1a44ec..e058769469866 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -344,8 +344,13 @@ describe("relative links inside a rendered host file", () => { filePath: "/tmp/report/appendix.md", workspaceRelativePath: null, }); - const inline = resolveInlineCodeFileLinkMeta("Makefile:12", "/repo", "/tmp/report"); - expect(inline).toMatchObject({ filePath: "/tmp/report/Makefile", line: 12 }); + const inline = resolveInlineCodeFileLinkMeta("Makefile:12-18", "/repo", "/tmp/report"); + expect(inline).toMatchObject({ + filePath: "/tmp/report/Makefile", + targetPath: "/tmp/report/Makefile:12-18", + line: 12, + endLine: 18, + }); expect(resolveMarkdownFileLinkMeta("src/main.ts", "/repo", "/repo/docs")).toMatchObject({ filePath: "/repo/docs/src/main.ts", workspaceRelativePath: "docs/src/main.ts", @@ -382,13 +387,59 @@ describe("resolveInlineCodeFileLinkMeta", () => { }); }); - it("links relative paths with line positions", () => { - expect( - resolveInlineCodeFileLinkMeta("src/processRunner.ts:71", "/Users/julius/project"), - ).toMatchObject({ - targetPath: "/Users/julius/project/src/processRunner.ts:71", - line: 71, - }); + it.each([ + { + source: "src/processRunner.ts:71", + cwd: "/Users/julius/project", + expected: { targetPath: "/Users/julius/project/src/processRunner.ts:71", line: 71 }, + }, + { + source: "src/processRunner.ts:49-74", + cwd: "/Users/julius/project", + expected: { + filePath: "/Users/julius/project/src/processRunner.ts", + targetPath: "/Users/julius/project/src/processRunner.ts:49-74", + displayPath: "project/src/processRunner.ts:49-74", + workspaceRelativePath: "src/processRunner.ts", + basename: "processRunner.ts", + line: 49, + endLine: 74, + }, + }, + { + source: "script.ts:49-74", + cwd: "/Users/julius/project", + expected: { + filePath: "/Users/julius/project/script.ts", + targetPath: "/Users/julius/project/script.ts:49-74", + workspaceRelativePath: "script.ts", + line: 49, + endLine: 74, + }, + }, + { + source: "/Users/julius/project/src/processRunner.ts:49-74", + cwd: undefined, + expected: { + filePath: "/Users/julius/project/src/processRunner.ts", + targetPath: "/Users/julius/project/src/processRunner.ts:49-74", + line: 49, + endLine: 74, + }, + }, + { + source: "src\\main.ts:49-74", + cwd: "C:\\Users\\mike\\project", + expected: { + filePath: "C:\\Users\\mike\\project\\src\\main.ts", + targetPath: "C:\\Users\\mike\\project\\src\\main.ts:49-74", + workspaceRelativePath: "src/main.ts", + line: 49, + endLine: 74, + }, + }, + ])("links $source with line positions", ({ source, cwd, expected }) => { + expect(resolveInlineCodeFileLinkMeta(source, cwd)).toMatchObject(expected); }); it("links bare filenames only when a line suffix marks them as file references", () => { diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 8ed59eb8775b6..ee595dd066ad5 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -27,6 +27,7 @@ export interface MarkdownFileLinkMeta { basename: string; line?: number; column?: number; + endLine?: number; } export function extractMarkdownLinkHrefs(markdown: string): string[] { @@ -107,7 +108,7 @@ export function resolveMarkdownFileLinkMeta( } function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): MarkdownFileLinkMeta { - const { path, line, column } = splitFilePathPosition(targetPath); + const { path, line, column, endLine } = splitFilePathPosition(targetPath); return { filePath: path, targetPath, @@ -116,5 +117,6 @@ function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): Markdown basename: fileBasename(path), ...(line !== undefined ? { line } : {}), ...(column !== undefined ? { column } : {}), + ...(endLine !== undefined ? { endLine } : {}), }; } diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts index b6e52e4534a12..6311ea7399737 100644 --- a/packages/client-runtime/src/markdownLinks.test.ts +++ b/packages/client-runtime/src/markdownLinks.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { fileBasename, + formatFilePathPosition, inlineCodeFilePathCandidate, isMarkdownFileLinkLabel, parseFileUrlHref, @@ -20,6 +21,9 @@ describe("isMarkdownFileLinkLabel", () => { ["example.ts:99", "/repo/src/example.ts:12", false], ["example.ts:12:2", "/repo/src/example.ts:12:2", true], ["example.ts:12:3", "/repo/src/example.ts:12:2", false], + ["example.ts:49-74", "/repo/src/example.ts:49-74", true], + ["example.ts:49-75", "/repo/src/example.ts:49-74", false], + ["example.ts:49-74", "/repo/src/example.ts:49", false], ["example.ts:12", "/repo/src/example.ts", false], ["src/example.ts:12", "/repo/src/example.ts:12", true], ["./src/example.ts", "/repo/src/example.ts", true], @@ -40,14 +44,21 @@ describe("inlineCodeFilePathCandidate", () => { ["\\\\server\\share\\image.png", "\\\\server\\share\\image.png"], ["conf.d/nginx.conf", "conf.d/nginx.conf"], ["script.pl:10", "script.pl:10"], + ["main.ts:49-74", "main.ts:49-74"], + ["src\\main.ts:49-74", "src/main.ts:49-74"], + ["Makefile:49-74", "Makefile:49-74"], + ["script.pl:49-74", "script.pl:49-74"], ["node.meta", null], ["Recorded evidence here: /tmp/image.png", null], ["origin/main", null], ["127.0.0.1:3000", null], + ["localhost:49-74", null], + ["example.com:49-74", null], ["example.com/index.html", null], ["example.pl/index.html", null], ["z-ai/glm-5.3", null], ["z-ai/glm-5.3:12", null], + ["z-ai/glm-5.3:49-74", null], ["python/3.12", null], ["Qwen/Qwen2.5-Coder", null], ["meta-llama/Llama-3.1-8B", null], @@ -91,6 +102,12 @@ describe("splitFilePathPosition", () => { ["src/main.ts", "", { path: "src/main.ts" }], ["src/main.ts:12", "", { path: "src/main.ts", line: 12 }], ["src/main.ts:12:5", "", { path: "src/main.ts", line: 12, column: 5 }], + ["src/main.ts:49-74", "", { path: "src/main.ts", line: 49, endLine: 74 }], + ["src/main.ts:49-49", "", { path: "src/main.ts", line: 49, endLine: 49 }], + ["src/main.ts:49-0", "", { path: "src/main.ts", line: 49 }], + ["src/main.ts:74-49", "", { path: "src/main.ts", line: 74 }], + ["src/main.ts:0-74", "", { path: "src/main.ts" }], + ["src/main.ts:49-", "", { path: "src/main.ts:49-" }], ["src/main.ts", "#L18C2", { path: "src/main.ts", line: 18, column: 2 }], ["src/main.ts:3", "#L18C2", { path: "src/main.ts", line: 3 }], ["src/main.ts:0", "", { path: "src/main.ts" }], @@ -124,6 +141,10 @@ describe("parseMarkdownFileLink", () => { ["~/notes/today.md", "~/notes/today.md"], ["AGENTS.md", "AGENTS.md"], ["script.ts:10", "script.ts"], + ["script.ts:49-74", "script.ts"], + ["apps/mobile/src/index.ts:49-74", "apps/mobile/src/index.ts"], + ["/Users/julius/project/src/main.ts:49-74", "/Users/julius/project/src/main.ts"], + ["C:\\Users\\mike\\project\\src\\main.ts:49-74", "C:\\Users\\mike\\project\\src\\main.ts"], ["/tmp/clip%23one.mp4#t=2", "/tmp/clip#one.mp4"], ])("recognizes %s as a file", (href, path) => { expect(parseMarkdownFileLink(href)?.path).toBe(path); @@ -141,6 +162,9 @@ describe("parseMarkdownFileLink", () => { "/app#L1", "readme", "TODO:12", + "TODO:49-74", + "script.ts:49-", + "script.ts:49-nope", ])("does not treat %s as a file", (href) => { expect(parseMarkdownFileLink(href)).toBeNull(); }); @@ -152,6 +176,9 @@ describe("parseMarkdownFileLink", () => { }); it("reads positions from suffixes and line anchors", () => { + const range = parseMarkdownFileLink("script.ts:49-74"); + expect(range).toEqual({ path: "script.ts", line: 49, endLine: 74 }); + expect(formatFilePathPosition(range!)).toBe("script.ts:49-74"); expect(parseMarkdownFileLink("/Users/julius/project/src/main.ts#L42C7")).toEqual({ path: "/Users/julius/project/src/main.ts", line: 42, diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/client-runtime/src/markdownLinks.ts index 88db0e5a725aa..cd29edade3886 100644 --- a/packages/client-runtime/src/markdownLinks.ts +++ b/packages/client-runtime/src/markdownLinks.ts @@ -3,14 +3,13 @@ import { isWindowsAbsolutePath } from "@t3tools/shared/path"; const SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN = /^\/[A-Za-z]:[\\/]/; const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; const RELATIVE_FILE_PATH_PATTERN = - /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; -const RELATIVE_FILE_NAME_PATTERN = - /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; + /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*$/; +const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+$/; const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; -const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; -const POSITION_SUFFIX_CAPTURE_PATTERN = /:(\d+)(?::(\d+))?$/; +const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+|-\d+)?$/; +const POSITION_SUFFIX_CAPTURE_PATTERN = /:(\d+)(?::(\d+)|-(\d+))?$/; const POSITION_HASH_PATTERN = /^#L(\d+)(?:C(\d+))?$/i; -const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; +const POSITION_ONLY_PATTERN = /^\d+(?::\d+|-\d+)?$/; const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; const PATH_SEPARATOR_PATTERN = /[\\/]/; const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; @@ -244,6 +243,7 @@ export interface FilePathPosition { readonly path: string; readonly line?: number; readonly column?: number; + readonly endLine?: number; } export function splitFilePathPosition(path: string, hash = ""): FilePathPosition { @@ -253,15 +253,18 @@ export function splitFilePathPosition(path: string, hash = ""): FilePathPosition const line = Number.parseInt(match[1], 10); const column = match[2] === undefined ? undefined : Number.parseInt(match[2], 10); + const endLine = match[3] === undefined ? undefined : Number.parseInt(match[3], 10); return { path: suffixMatch ? path.slice(0, -suffixMatch[0].length) : path, ...(line > 0 ? { line } : {}), ...(column !== undefined && column > 0 ? { column } : {}), + ...(line > 0 && endLine !== undefined && endLine >= line ? { endLine } : {}), }; } export function formatFilePathPosition(position: FilePathPosition): string { if (!position.line) return position.path; + if (position.endLine) return `${position.path}:${position.line}-${position.endLine}`; return `${position.path}:${position.line}${position.column ? `:${position.column}` : ""}`; } @@ -274,7 +277,8 @@ export function isMarkdownFileLinkLabel(label: string, href: string): boolean { const labelPosition = splitFilePathPosition(label.trim()); if ( (labelPosition.line !== undefined && labelPosition.line !== destination.line) || - (labelPosition.column !== undefined && labelPosition.column !== destination.column) + (labelPosition.column !== undefined && labelPosition.column !== destination.column) || + (labelPosition.endLine !== undefined && labelPosition.endLine !== destination.endLine) ) { return false; } @@ -312,7 +316,7 @@ function looksLikeFilePath(path: string, authoredPath: string): boolean { if (isWindowsAbsolutePath(path) || RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true; if (path.startsWith("/")) return looksLikePosixFilesystemPath(authoredPath); if (EXTENSIONLESS_FILE_NAMES.has(path)) return true; - return RELATIVE_FILE_PATH_PATTERN.test(authoredPath) || RELATIVE_FILE_NAME_PATTERN.test(path); + return RELATIVE_FILE_PATH_PATTERN.test(path) || RELATIVE_FILE_NAME_PATTERN.test(path); } function hasExternalScheme(path: string): boolean { From f7230fb3aa6c19142d1aba40ebc6d2befaebb74c Mon Sep 17 00:00:00 2001 From: Ethan <9874748+ethanblake4@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:36:35 -0700 Subject: [PATCH 2/6] fix(clients): preserve file labels with mismatched line ranges --- packages/client-runtime/src/markdownLinks.test.ts | 2 ++ packages/client-runtime/src/markdownLinks.ts | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts index 6311ea7399737..adc9496140b3b 100644 --- a/packages/client-runtime/src/markdownLinks.test.ts +++ b/packages/client-runtime/src/markdownLinks.test.ts @@ -22,6 +22,8 @@ describe("isMarkdownFileLinkLabel", () => { ["example.ts:12:2", "/repo/src/example.ts:12:2", true], ["example.ts:12:3", "/repo/src/example.ts:12:2", false], ["example.ts:49-74", "/repo/src/example.ts:49-74", true], + ["example.ts", "/repo/src/example.ts:49-74", true], + ["example.ts:49", "/repo/src/example.ts:49-74", false], ["example.ts:49-75", "/repo/src/example.ts:49-74", false], ["example.ts:49-74", "/repo/src/example.ts:49", false], ["example.ts:12", "/repo/src/example.ts", false], diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/client-runtime/src/markdownLinks.ts index cd29edade3886..3b0fd119eb4d2 100644 --- a/packages/client-runtime/src/markdownLinks.ts +++ b/packages/client-runtime/src/markdownLinks.ts @@ -276,9 +276,9 @@ export function isMarkdownFileLinkLabel(label: string, href: string): boolean { path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, ""); const labelPosition = splitFilePathPosition(label.trim()); if ( - (labelPosition.line !== undefined && labelPosition.line !== destination.line) || - (labelPosition.column !== undefined && labelPosition.column !== destination.column) || - (labelPosition.endLine !== undefined && labelPosition.endLine !== destination.endLine) + (labelPosition.line !== undefined && + (labelPosition.line !== destination.line || labelPosition.endLine !== destination.endLine)) || + (labelPosition.column !== undefined && labelPosition.column !== destination.column) ) { return false; } From 489e21d87a78340925fbc453ce7e1ffcf5f1a387 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 15:48:28 +0000 Subject: [PATCH 3/6] fix(clients): recognize colon line ranges in file chips --- .../src/features/threads/fileChipMenu.test.ts | 15 +++- .../src/features/threads/fileChipMenu.ts | 12 ++- .../src/lib/enrichedLinkPresentation.test.ts | 30 +++++-- .../src/lib/enrichedLinkPresentation.ts | 14 +++- apps/mobile/src/lib/markdownLinks.test.ts | 21 +++-- .../src/native/EnrichedMarkdownText.tsx | 6 +- .../src/native/useEnrichedLinkVariants.ts | 19 ++++- apps/web/src/markdown-links.test.ts | 84 ++++++------------- packages/shared/src/fileLinks.test.ts | 6 -- packages/shared/src/markdownLinks.test.ts | 20 +---- packages/shared/src/threadFindText.test.ts | 3 + 11 files changed, 116 insertions(+), 114 deletions(-) diff --git a/apps/mobile/src/features/threads/fileChipMenu.test.ts b/apps/mobile/src/features/threads/fileChipMenu.test.ts index 1627a72da5dec..783b8d7aea06f 100644 --- a/apps/mobile/src/features/threads/fileChipMenu.test.ts +++ b/apps/mobile/src/features/threads/fileChipMenu.test.ts @@ -6,8 +6,19 @@ import { fileChipMenu, fileChipShareSource, resolveFileChipTarget } from "./file describe("resolveFileChipTarget", () => { it("resolves a workspace-relative link to both paths", () => { expect(resolveFileChipTarget("src/app.ts:12", "/repo")).toEqual({ - fullPath: "/repo/src/app.ts", - relativePath: "src/app.ts", + fullPath: "/repo/src/app.ts:12", + relativePath: "src/app.ts:12", + }); + }); + + it("keeps ranges in copied paths while using the bare path for downloads", () => { + const target = resolveFileChipTarget("docs/report.pdf:49-74", "/repo")!; + expect(target).toEqual({ + fullPath: "/repo/docs/report.pdf:49-74", + relativePath: "docs/report.pdf:49-74", + }); + expect(fileChipShareSource(target, ThreadId.make("thread-1"))).toMatchObject({ + resource: { path: "/repo/docs/report.pdf" }, }); }); diff --git a/apps/mobile/src/features/threads/fileChipMenu.ts b/apps/mobile/src/features/threads/fileChipMenu.ts index d55d3e4b59649..f5167867b3dc9 100644 --- a/apps/mobile/src/features/threads/fileChipMenu.ts +++ b/apps/mobile/src/features/threads/fileChipMenu.ts @@ -1,4 +1,5 @@ import { fileBasename } from "@t3tools/shared/path"; +import { formatFilePathPosition, splitFilePathPosition } from "@t3tools/shared/fileLinks"; import type { ThreadId } from "@t3tools/contracts"; import { resolveMarkdownLinkPresentation } from "../../lib/markdownLinks"; import type { MarkdownFileContextMenu } from "../../native/SelectableMarkdownText.types"; @@ -31,14 +32,17 @@ export function resolveFileChipTarget( : undefined; if (!fullPath && !relativePath) return null; return { - ...(fullPath ? { fullPath } : {}), - ...(relativePath ? { relativePath } : {}), + ...(fullPath ? { fullPath: formatFilePathPosition({ ...presentation, path: fullPath }) } : {}), + ...(relativePath + ? { relativePath: formatFilePathPosition({ ...presentation, path: relativePath }) } + : {}), }; } function fileChipMetadata(target: FileChipTarget) { - const path = target.fullPath ?? target.relativePath; - if (!path) return null; + const targetPath = target.fullPath ?? target.relativePath; + if (!targetPath) return null; + const { path } = splitFilePathPosition(targetPath); const name = fileBasename(path); const dot = name.lastIndexOf("."); const mimeType = dot < 0 ? null : hostPreviewMimeTypeFromExtension(name.slice(dot)); diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts index a6730ce858c51..88ebc07ddde00 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts @@ -5,31 +5,45 @@ import { enrichedLinkVariantPattern, enrichedSkillLinkRegex, enrichedSkillReference, - ENRICHED_INLINE_FILE_LINK_REGEX, + enrichedInlineFileLinkRegex, } from "./enrichedLinkPresentation"; describe("Enriched link presentation", () => { it("recognizes file-shaped code without turning commands or URLs into links", () => { - for (const path of [ + const paths = [ "src/app.tsx", "./README.md", "/tmp/report.md", "C:\\repo\\app.tsx", "app.tsx:12:3", - ]) { - expect(ENRICHED_INLINE_FILE_LINK_REGEX.test(path), path).toBe(true); - } - for (const text of [ + "src/main.ts:49-74", + "/repo/src/main.ts:49-74", + "main.ts:49-74", + ]; + const excluded = [ "npm run dev", "port:3000", "TODO:12", "example.com", + "example.com:49-74", + "localhost:49-74", + "z-ai/glm-5.3:49-74", "https://example.com/app.tsx", "x + y", "README.md", "/app/settings", - ]) { - expect(ENRICHED_INLINE_FILE_LINK_REGEX.test(text), text).toBe(false); + ]; + const markdown = [ + "```md\n`other.ts:49-74`\n```", + ...paths.map((path) => "``" + path + "``"), + ...excluded.map((text) => "`" + text + "`"), + ].join("\n"); + const regex = enrichedInlineFileLinkRegex(markdown); + for (const path of paths) { + expect(regex?.test(path), path).toBe(true); + } + for (const text of [...excluded, "other.ts:49-74"]) { + expect(regex?.test(text) ?? false, text).toBe(false); } }); diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.ts b/apps/mobile/src/lib/enrichedLinkPresentation.ts index 1c68169198114..2dd5db1dc0aba 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.ts @@ -1,6 +1,6 @@ import type { ComposerContextRecord } from "@t3tools/contracts"; import { contextChipPresentation } from "./composerChipPresentation"; -import type { MarkdownFileIcon } from "./markdownLinks"; +import { resolveMarkdownInlineCodePresentation, type MarkdownFileIcon } from "./markdownLinks"; import type { SelectableMarkdownSkill } from "../native/SelectableMarkdownText.types"; import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; @@ -33,8 +33,16 @@ export function enrichedLinkVariantPattern(url: string) { return `^${url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`; } -export const ENRICHED_INLINE_FILE_LINK_REGEX = - /^(?!\S*:\/\/)(?:(?:\.{1,2}[\\/]|~[\\/]|\/(?:Users|home|tmp|var|etc|opt|mnt|Volumes|private|root|usr|bin|sbin|lib|lib64|srv|dev|proc|sys|run|boot|media|workspace|workspaces)\/|[A-Za-z]:[\\/])[^\s`]+|(?:[A-Za-z0-9._-]+[\\/])+[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}|[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+:\d+(?::\d+)?)$/; +export function enrichedInlineFileLinkRegex(markdown: string) { + const paths = new Set(); + for (const match of markdown.matchAll(/(`+)([\s\S]*?)\1/g)) { + const content = match[2]!.trim(); + if (resolveMarkdownInlineCodePresentation(content)) paths.add(content); + } + return paths.size > 0 + ? new RegExp([...paths].map(enrichedLinkVariantPattern).join("|")) + : undefined; +} export function enrichedSkillLinkRegex(skills?: ReadonlyArray) { const names = [...new Set(skills?.map((skill) => skill.name) ?? [])].filter(Boolean); diff --git a/apps/mobile/src/lib/markdownLinks.test.ts b/apps/mobile/src/lib/markdownLinks.test.ts index 67e1895b35df5..db20243dc36b7 100644 --- a/apps/mobile/src/lib/markdownLinks.test.ts +++ b/apps/mobile/src/lib/markdownLinks.test.ts @@ -14,6 +14,18 @@ describe("resolveMarkdownLinkIcon", () => { }); describe("resolveMarkdownLinkPresentation", () => { + it("keeps a source range in the label and href while selecting the file icon", () => { + expect(resolveMarkdownLinkPresentation("src/main.ts:49-74")).toEqual({ + kind: "file", + href: "src/main.ts:49-74", + icon: "typescript", + label: "main.ts:49-74", + path: "src/main.ts", + line: 49, + endLine: 74, + }); + }); + it("keeps thread-link hrefs for in-app navigation", () => { const href = "t3-thread://v1/thread-1"; expect(resolveMarkdownLinkPresentation(href)).toEqual({ kind: "link", href }); @@ -59,15 +71,6 @@ describe("resolveMarkdownLinkPresentation", () => { }); it("recognizes relative source paths and bare filenames", () => { - expect(resolveMarkdownLinkPresentation("apps/mobile/src/index.ts:49-74")).toEqual({ - kind: "file", - href: "apps/mobile/src/index.ts:49-74", - icon: "typescript", - label: "index.ts:49-74", - path: "apps/mobile/src/index.ts", - line: 49, - endLine: 74, - }); expect(resolveMarkdownLinkPresentation("apps/mobile/src/index.ts:10")).toEqual({ kind: "file", href: "apps/mobile/src/index.ts:10", diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx index 7fb3a51281ef0..dcf22edd220ee 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -20,7 +20,7 @@ import { Menu } from "../components/ui/Menu"; import { useEnrichedLinkVariants } from "./useEnrichedLinkVariants"; import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; import { - ENRICHED_INLINE_FILE_LINK_REGEX, + enrichedInlineFileLinkRegex, enrichedSkillLinkRegex, enrichedLinkVariantPattern, } from "../lib/enrichedLinkPresentation"; @@ -169,9 +169,9 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { const linkRecognition = useMemo( () => ({ text: enrichedSkillLinkRegex(props.skills) ?? undefined, - inlineCode: ENRICHED_INLINE_FILE_LINK_REGEX, + inlineCode: enrichedInlineFileLinkRegex(markdown), }), - [props.skills], + [props.skills, markdown], ); const [activeMenu, setActiveMenu] = useState<{ url: string; diff --git a/apps/mobile/src/native/useEnrichedLinkVariants.ts b/apps/mobile/src/native/useEnrichedLinkVariants.ts index 81ed0881c2f1c..e6c19cb39a9ea 100644 --- a/apps/mobile/src/native/useEnrichedLinkVariants.ts +++ b/apps/mobile/src/native/useEnrichedLinkVariants.ts @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; import { Image } from "react-native"; import type { LinkPillContent, @@ -19,6 +20,7 @@ import { function presentation( url: string, props: Pick, + preserveLabel: boolean, ) { const custom = props.linkCustomization?.(url); if (custom) return custom; @@ -35,7 +37,7 @@ function presentation( if (link.kind === "file") return { color: props.textStyle.fileTextColor, - label: link.label, + label: preserveLabel ? undefined : link.label, icon: markdownFileIconSource(link.icon), }; const icon = link.kind === "external" ? resolveMarkdownLinkIcon(link.host) : null; @@ -54,10 +56,23 @@ export function useEnrichedLinkVariants( ) { const { linkCustomization, skills, textStyle } = props; const links = useMemo(() => { + // Pill content is keyed by URL, so preserve every occurrence's native label + // when any authored label differs from its destination. + const authoredLabelUrls = new Set( + assets + .filter( + (asset) => asset.kind === "link" && !isMarkdownFileLinkLabel(asset.altText, asset.url), + ) + .map((asset) => asset.url), + ); const byUrl = new Map>>(); for (const asset of assets) { if (asset.kind !== "link" || byUrl.has(asset.url)) continue; - const value = presentation(asset.url, { linkCustomization, skills, textStyle }); + const value = presentation( + asset.url, + { linkCustomization, skills, textStyle }, + authoredLabelUrls.has(asset.url), + ); if (value) byUrl.set(asset.url, value); } return byUrl; diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index c4b7f784e9b13..ce09f8e3526c2 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -84,13 +84,8 @@ describe("relative links inside a rendered host file", () => { filePath: "/tmp/report/appendix.md", workspaceRelativePath: null, }); - const inline = resolveInlineCodeFileLinkMeta("Makefile:12-18", "/repo", "/tmp/report"); - expect(inline).toMatchObject({ - filePath: "/tmp/report/Makefile", - targetPath: "/tmp/report/Makefile:12-18", - line: 12, - endLine: 18, - }); + const inline = resolveInlineCodeFileLinkMeta("Makefile:12", "/repo", "/tmp/report"); + expect(inline).toMatchObject({ filePath: "/tmp/report/Makefile", line: 12 }); expect(resolveMarkdownFileLinkMeta("src/main.ts", "/repo", "/repo/docs")).toMatchObject({ filePath: "/repo/docs/src/main.ts", workspaceRelativePath: "docs/src/main.ts", @@ -128,6 +123,21 @@ describe("relative links inside a rendered host file", () => { }); describe("resolveInlineCodeFileLinkMeta", () => { + it.each(["src/main.ts:49-74", "/work/src/main.ts:49-74"])( + "resolves %s without treating the range as part of the filename", + (source) => { + expect(resolveInlineCodeFileLinkMeta(source, "/work")).toMatchObject({ + filePath: "/work/src/main.ts", + targetPath: "/work/src/main.ts:49-74", + displayPath: "work/src/main.ts:49-74", + workspaceRelativePath: "src/main.ts", + basename: "main.ts", + line: 49, + endLine: 74, + }); + }, + ); + it("links relative paths with file extensions", () => { expect( resolveInlineCodeFileLinkMeta("docs/internals/workspace-layout.md", "/Users/julius/project"), @@ -156,59 +166,13 @@ describe("resolveInlineCodeFileLinkMeta", () => { }); }); - it.each([ - { - source: "src/processRunner.ts:71", - cwd: "/Users/julius/project", - expected: { targetPath: "/Users/julius/project/src/processRunner.ts:71", line: 71 }, - }, - { - source: "src/processRunner.ts:49-74", - cwd: "/Users/julius/project", - expected: { - filePath: "/Users/julius/project/src/processRunner.ts", - targetPath: "/Users/julius/project/src/processRunner.ts:49-74", - displayPath: "project/src/processRunner.ts:49-74", - workspaceRelativePath: "src/processRunner.ts", - basename: "processRunner.ts", - line: 49, - endLine: 74, - }, - }, - { - source: "script.ts:49-74", - cwd: "/Users/julius/project", - expected: { - filePath: "/Users/julius/project/script.ts", - targetPath: "/Users/julius/project/script.ts:49-74", - workspaceRelativePath: "script.ts", - line: 49, - endLine: 74, - }, - }, - { - source: "/Users/julius/project/src/processRunner.ts:49-74", - cwd: undefined, - expected: { - filePath: "/Users/julius/project/src/processRunner.ts", - targetPath: "/Users/julius/project/src/processRunner.ts:49-74", - line: 49, - endLine: 74, - }, - }, - { - source: "src\\main.ts:49-74", - cwd: "C:\\Users\\mike\\project", - expected: { - filePath: "C:\\Users\\mike\\project\\src\\main.ts", - targetPath: "C:\\Users\\mike\\project\\src\\main.ts:49-74", - workspaceRelativePath: "src/main.ts", - line: 49, - endLine: 74, - }, - }, - ])("links $source with line positions", ({ source, cwd, expected }) => { - expect(resolveInlineCodeFileLinkMeta(source, cwd)).toMatchObject(expected); + it("links relative paths with line positions", () => { + expect( + resolveInlineCodeFileLinkMeta("src/processRunner.ts:71", "/Users/julius/project"), + ).toMatchObject({ + targetPath: "/Users/julius/project/src/processRunner.ts:71", + line: 71, + }); }); it("links bare filenames only when a line suffix marks them as file references", () => { diff --git a/packages/shared/src/fileLinks.test.ts b/packages/shared/src/fileLinks.test.ts index 3ff8eedbdfadd..280756ed244f4 100644 --- a/packages/shared/src/fileLinks.test.ts +++ b/packages/shared/src/fileLinks.test.ts @@ -32,12 +32,6 @@ describe("splitFilePathPosition", () => { ["src/main.ts", "", { path: "src/main.ts" }], ["src/main.ts:12", "", { path: "src/main.ts", line: 12 }], ["src/main.ts:12:5", "", { path: "src/main.ts", line: 12, column: 5 }], - ["src/main.ts:49-74", "", { path: "src/main.ts", line: 49, endLine: 74 }], - ["src/main.ts:49-49", "", { path: "src/main.ts", line: 49, endLine: 49 }], - ["src/main.ts:49-0", "", { path: "src/main.ts", line: 49 }], - ["src/main.ts:74-49", "", { path: "src/main.ts", line: 74 }], - ["src/main.ts:0-74", "", { path: "src/main.ts" }], - ["src/main.ts:49-", "", { path: "src/main.ts:49-" }], ["src/main.ts", "#L18C2", { path: "src/main.ts", line: 18, column: 2 }], ["src/main.ts:3", "#L18C2", { path: "src/main.ts", line: 3 }], ["src/main.ts:0", "", { path: "src/main.ts" }], diff --git a/packages/shared/src/markdownLinks.test.ts b/packages/shared/src/markdownLinks.test.ts index e72fb920169cc..ca4bab99fab43 100644 --- a/packages/shared/src/markdownLinks.test.ts +++ b/packages/shared/src/markdownLinks.test.ts @@ -1,4 +1,4 @@ -import { formatFilePathPosition, splitFilePathPosition } from "./fileLinks.ts"; +import { splitFilePathPosition } from "./fileLinks.ts"; import { parseFileUrlHref } from "./fileLinks.ts"; import { workspaceRelativeFilePath, fileBasename } from "./path.ts"; import { describe, expect, it } from "vite-plus/test"; @@ -24,8 +24,8 @@ describe("isMarkdownFileLinkLabel", () => { ["example.ts:99", "/repo/src/example.ts:12", false], ["example.ts:12:2", "/repo/src/example.ts:12:2", true], ["example.ts:12:3", "/repo/src/example.ts:12:2", false], - ["example.ts:49-74", "/repo/src/example.ts:49-74", true], ["example.ts", "/repo/src/example.ts:49-74", true], + ["example.ts:49-74", "/repo/src/example.ts:49-74", true], ["example.ts:49", "/repo/src/example.ts:49-74", false], ["example.ts:49-75", "/repo/src/example.ts:49-74", false], ["example.ts:49-74", "/repo/src/example.ts:49", false], @@ -49,21 +49,17 @@ describe("inlineCodeFilePathCandidate", () => { ["\\\\server\\share\\image.png", "\\\\server\\share\\image.png"], ["conf.d/nginx.conf", "conf.d/nginx.conf"], ["script.pl:10", "script.pl:10"], - ["main.ts:49-74", "main.ts:49-74"], - ["src\\main.ts:49-74", "src/main.ts:49-74"], - ["Makefile:49-74", "Makefile:49-74"], - ["script.pl:49-74", "script.pl:49-74"], ["node.meta", null], ["Recorded evidence here: /tmp/image.png", null], ["origin/main", null], ["127.0.0.1:3000", null], ["localhost:49-74", null], ["example.com:49-74", null], + ["z-ai/glm-5.3:49-74", null], ["example.com/index.html", null], ["example.pl/index.html", null], ["z-ai/glm-5.3", null], ["z-ai/glm-5.3:12", null], - ["z-ai/glm-5.3:49-74", null], ["python/3.12", null], ["Qwen/Qwen2.5-Coder", null], ["meta-llama/Llama-3.1-8B", null], @@ -138,10 +134,6 @@ describe("parseMarkdownFileLink", () => { ["~/notes/today.md", "~/notes/today.md"], ["AGENTS.md", "AGENTS.md"], ["script.ts:10", "script.ts"], - ["script.ts:49-74", "script.ts"], - ["apps/mobile/src/index.ts:49-74", "apps/mobile/src/index.ts"], - ["/Users/julius/project/src/main.ts:49-74", "/Users/julius/project/src/main.ts"], - ["C:\\Users\\mike\\project\\src\\main.ts:49-74", "C:\\Users\\mike\\project\\src\\main.ts"], ["/tmp/clip%23one.mp4#t=2", "/tmp/clip#one.mp4"], ])("recognizes %s as a file", (href, path) => { expect(parseMarkdownFileLink(href)?.path).toBe(path); @@ -159,9 +151,6 @@ describe("parseMarkdownFileLink", () => { "/app#L1", "readme", "TODO:12", - "TODO:49-74", - "script.ts:49-", - "script.ts:49-nope", ])("does not treat %s as a file", (href) => { expect(parseMarkdownFileLink(href)).toBeNull(); }); @@ -173,9 +162,6 @@ describe("parseMarkdownFileLink", () => { }); it("reads positions from suffixes and line anchors", () => { - const range = parseMarkdownFileLink("script.ts:49-74"); - expect(range).toEqual({ path: "script.ts", line: 49, endLine: 74 }); - expect(formatFilePathPosition(range!)).toBe("script.ts:49-74"); expect(parseMarkdownFileLink("/Users/julius/project/src/main.ts#L42C7")).toEqual({ path: "/Users/julius/project/src/main.ts", line: 42, diff --git a/packages/shared/src/threadFindText.test.ts b/packages/shared/src/threadFindText.test.ts index fe3b81c68b142..09d0aaab2d29d 100644 --- a/packages/shared/src/threadFindText.test.ts +++ b/packages/shared/src/threadFindText.test.ts @@ -83,6 +83,9 @@ it("searches displayed prose and file-chip labels without hidden paths", () => { "label main.ts · L3:C2", ]); expect(assistantSegments("[label](src/main.ts#L3C2)")).toEqual(["label"]); +}); + +it("searches range chip labels while retaining a separately authored line label", () => { expect( assistantSegments( "`src/main.ts:49-74` and [example.ts:49](src/example.ts:49-74)", From c4cce44c34a3d05601e384e9b31c910d4fd40746 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 21:20:38 +0000 Subject: [PATCH 4/6] fix(mobile): preserve file chips after stray backticks --- apps/mobile/src/lib/enrichedLinkPresentation.test.ts | 8 +++++++- apps/mobile/src/lib/enrichedLinkPresentation.ts | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts index 88ebc07ddde00..9c731af04651e 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts @@ -42,9 +42,15 @@ describe("Enriched link presentation", () => { for (const path of paths) { expect(regex?.test(path), path).toBe(true); } - for (const text of [...excluded, "other.ts:49-74"]) { + for (const text of excluded) { expect(regex?.test(text) ?? false, text).toBe(false); } + for (const prefix of ["Use \\` here then", "~~~\na ` b\n~~~\n\nsee"]) { + const regex = enrichedInlineFileLinkRegex(prefix + " `src/a.ts` and `src/a.ts:1-2`"); + for (const path of ["src/a.ts", "src/a.ts:1-2"]) { + expect.soft(regex?.test(path) ?? false, `${prefix}: ${path}`).toBe(true); + } + } }); it("only recognizes complete known skill names and escapes their punctuation", () => { diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.ts b/apps/mobile/src/lib/enrichedLinkPresentation.ts index 2dd5db1dc0aba..88fd16a7edab8 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.ts @@ -35,8 +35,8 @@ export function enrichedLinkVariantPattern(url: string) { export function enrichedInlineFileLinkRegex(markdown: string) { const paths = new Set(); - for (const match of markdown.matchAll(/(`+)([\s\S]*?)\1/g)) { - const content = match[2]!.trim(); + for (const piece of markdown.split(/`+/)) { + const content = piece.trim(); if (resolveMarkdownInlineCodePresentation(content)) paths.add(content); } return paths.size > 0 From e2abd85a7b7c95a7fe949794884633eb65c2eb72 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 11:19:51 +0000 Subject: [PATCH 5/6] test(web): adapt range coverage to disposable render helper --- .../ChatMarkdown.permissions.browser.test.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx index 756c94f9e7b99..5bedf3e4f3a1e 100644 --- a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx @@ -175,25 +175,26 @@ async function setAllowed(allowed: boolean) { } it("displays and copies a code span range but opens at its start line", async () => { - await renderMarkdown("`src/main.ts:49-74`"); - expect(anchor().textContent).toBe("main.ts · L49-74"); + using _setup = setup(); + await using screen = await renderMarkdown("`src/main.ts:49-74`"); + expect(screen.anchor().textContent).toBe("main.ts · L49-74"); state.choose.mockResolvedValue("copy-relative"); - await openContextMenu(); - await vi.waitFor(() => expect(state.copy).toHaveBeenLastCalledWith("work/src/main.ts:49-74")); + await screen.openContextMenu(); + await vi.waitFor(() => expect(state.copy).toHaveBeenLastCalledWith("src/main.ts:49-74")); state.choose.mockResolvedValue("copy-full"); - await openContextMenu(); + await screen.openContextMenu(); await vi.waitFor(() => expect(state.copy).toHaveBeenLastCalledWith("/work/src/main.ts:49-74")); state.choose.mockResolvedValue("open"); - await openContextMenu(); + await screen.openContextMenu(); await vi.waitFor(() => expect(state.openEditor).toHaveBeenCalledExactlyOnceWith({ environmentId: threadRef.environmentId, input: { cwd: "/work/src/main.ts:49", editor: "vscode" }, }), ); - await act(async () => anchor().click()); + await act(async () => screen.anchor().click()); expect(state.openFile).toHaveBeenCalledWith(threadRef, "src/main.ts", 49); }); From edf1b63b32a0e8c1d0dcd4aba8f3129b58130e87 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 11:27:00 +0000 Subject: [PATCH 6/6] fix(mobile): use shared parsing for inline file discovery --- .../src/lib/enrichedLinkPresentation.test.ts | 11 ++++-- .../src/lib/enrichedLinkPresentation.ts | 3 -- apps/mobile/src/lib/markdownDocument.ts | 21 +++++----- .../src/lib/markdownInlineFileLinks.test.ts | 38 ++++++++++--------- .../src/native/EnrichedMarkdownText.tsx | 8 +--- 5 files changed, 40 insertions(+), 41 deletions(-) diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts index 89155b11a3cc3..721911ff72f6d 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts @@ -1,10 +1,10 @@ +import { markdownWithInlineFileLinks, parseMarkdownDocument } from "./markdownDocument"; import { describe, expect, it } from "vite-plus/test"; import type { ComposerContextId, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { enrichedLinkVariantPattern, enrichedSkillLinkRegex, enrichedThreadLinkPresentation, - ENRICHED_INLINE_FILE_LINK_REGEX, threadChipTargets, } from "./enrichedLinkPresentation"; @@ -17,7 +17,12 @@ describe("Enriched link presentation", () => { "C:\\repo\\app.tsx", "app.tsx:12:3", ]) { - expect(ENRICHED_INLINE_FILE_LINK_REGEX.test(path), path).toBe(true); + expect( + parseMarkdownDocument(markdownWithInlineFileLinks("`" + path + "`")), + path, + ).toMatchObject({ + children: [{ type: "paragraph", children: [{ type: "link", url: path }] }], + }); } for (const text of [ "npm run dev", @@ -29,7 +34,7 @@ describe("Enriched link presentation", () => { "README.md", "/app/settings", ]) { - expect(ENRICHED_INLINE_FILE_LINK_REGEX.test(text), text).toBe(false); + expect(markdownWithInlineFileLinks("`" + text + "`"), text).toBe("`" + text + "`"); } }); diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.ts b/apps/mobile/src/lib/enrichedLinkPresentation.ts index 36a9a5c7b03de..abcb1192a1035 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.ts @@ -80,9 +80,6 @@ export function enrichedLinkVariantPattern(url: string) { return `^${url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`; } -export const ENRICHED_INLINE_FILE_LINK_REGEX = - /^(?!\S*:\/\/)(?:(?:\.{1,2}[\\/]|~[\\/]|\/(?:Users|home|tmp|var|etc|opt|mnt|Volumes|private|root|usr|bin|sbin|lib|lib64|srv|dev|proc|sys|run|boot|media|workspace|workspaces)\/|[A-Za-z]:[\\/])[^\s`]+|(?:[A-Za-z0-9._-]+[\\/])+[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+(?::\d+|-\d+)?)?|[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+:\d+(?::\d+|-\d+)?)$/; - export function enrichedSkillLinkRegex(skills?: ReadonlyArray) { const names = [...new Set(skills?.map((skill) => skill.name) ?? [])].filter(Boolean); if (names.length === 0) return null; diff --git a/apps/mobile/src/lib/markdownDocument.ts b/apps/mobile/src/lib/markdownDocument.ts index 2adc813093f25..cc0050d7f18da 100644 --- a/apps/mobile/src/lib/markdownDocument.ts +++ b/apps/mobile/src/lib/markdownDocument.ts @@ -1,4 +1,4 @@ -import { inlineCodeFilePathCandidate } from "@t3tools/shared/markdownLinks"; +import { inlineCodeFilePathCandidate, parseMarkdownFileLink } from "@t3tools/shared/markdownLinks"; import type { Root, RootContent } from "mdast"; import { unified } from "unified"; import remarkParse from "remark-parse"; @@ -284,20 +284,20 @@ export function markdownWithHtml(markdown: string): string { // Start only at the beginning of a backtick run to avoid retrying every suffix. const INLINE_CODE_SPAN = /(?]/.test(node.value) ) { const edit = { @@ -381,16 +380,16 @@ function mayHaveTableUnderText(markdown: string): boolean { /** md4c starts tables only at paragraph starts; insert breaks to match GFM. */ export function markdownWithTableBreaks(markdown: string): string { - return transformNativeMarkdown(markdown, undefined, true); + return transformNativeMarkdown(markdown, false, true); } export function parseMarkdownDocument(markdown: string): Root { return parser.parse(markdownWithHtml(markdown)); } -export function markdownForNativeRendering(markdown: string, fileLink: RegExp): string { +export function markdownForNativeRendering(markdown: string): string { // HTML wrappers can expose Markdown that wasn't in the first AST, so normalize them first. - return transformNativeMarkdown(markdownWithHtml(markdown), fileLink, true); + return transformNativeMarkdown(markdownWithHtml(markdown), true, true); } /** Native media slots only cover standalone images; nested file embeds must become links. */ diff --git a/apps/mobile/src/lib/markdownInlineFileLinks.test.ts b/apps/mobile/src/lib/markdownInlineFileLinks.test.ts index 963893bf32c79..cc4f0fd973a59 100644 --- a/apps/mobile/src/lib/markdownInlineFileLinks.test.ts +++ b/apps/mobile/src/lib/markdownInlineFileLinks.test.ts @@ -3,7 +3,7 @@ import { unified } from "unified"; import remarkParse from "remark-parse"; import remarkGfm from "remark-gfm"; import type { Root, RootContent } from "mdast"; -import { ENRICHED_INLINE_FILE_LINK_REGEX } from "./enrichedLinkPresentation"; +import { inlineCodeFilePathCandidate, parseMarkdownFileLink } from "@t3tools/shared/markdownLinks"; import { markdownForNativeRendering, markdownWithInlineFileLinks, @@ -11,8 +11,7 @@ import { markdownWithHtml, } from "./markdownDocument"; -const rewrite = (markdown: string) => - markdownWithInlineFileLinks(markdown, ENRICHED_INLINE_FILE_LINK_REGEX); +const rewrite = (markdown: string) => markdownWithInlineFileLinks(markdown); describe("inline code that names a file", () => { it.each([ @@ -58,13 +57,8 @@ describe("inline code that names a file", () => { const document = fixture.replaceAll("\n", ending); for (let length = 0; length <= document.length; length++) { const prefix = document.slice(0, length); - const expected = previousTableBreaks( - previousFileLinks(markdownWithHtml(prefix), ENRICHED_INLINE_FILE_LINK_REGEX), - ); - expect( - markdownForNativeRendering(prefix, ENRICHED_INLINE_FILE_LINK_REGEX), - JSON.stringify(prefix), - ).toBe(expected); + const expected = previousTableBreaks(previousFileLinks(markdownWithHtml(prefix))); + expect(markdownForNativeRendering(prefix), JSON.stringify(prefix)).toBe(expected); } } }); @@ -75,11 +69,15 @@ describe("inline code that names a file", () => { ); }); - it("links line ranges while leaving fenced ranges literal", () => { - expect(rewrite("See `src/main.ts:49-74` and `main.ts:49-74`.")).toBe( - "See [src/main.ts:49-74]() and [main.ts:49-74]().", - ); - const fence = "```\nsrc/main.ts:49-74\n```"; + it.each([ + "src/main.ts:49-74", + "main.ts:49-74", + "/repo/src/main.ts:49-74", + "/work/src/main.ts:49-74", + "Makefile:49-74", + ])("links %s while leaving fenced ranges literal", (path) => { + expect(rewrite("See `" + path + "`.")).toBe(`See [${path}](<${path}>).`); + const fence = "```\n" + path + "\n```"; expect(rewrite(fence)).toBe(fence); }); @@ -110,8 +108,12 @@ const parser = unified().use(remarkParse).use(remarkGfm); type MarkdownNode = Root | RootContent; const INLINE_CODE_SPAN = /`+([^`\r\n]+)`+/g; -function previousFileLinks(markdown: string, fileLink: RegExp): string { - if (![...markdown.matchAll(INLINE_CODE_SPAN)].some(([, code]) => fileLink.test(code!.trim()))) +function previousFileLinks(markdown: string): string { + if ( + ![...markdown.matchAll(INLINE_CODE_SPAN)].some( + ([, code]) => parseMarkdownFileLink(inlineCodeFilePathCandidate(code!.trim()) ?? "") !== null, + ) + ) return markdown; const edits: Array<{ start: number; end: number; content: string }> = []; const visit = (node: MarkdownNode) => { @@ -122,7 +124,7 @@ function previousFileLinks(markdown: string, fileLink: RegExp): string { if ( start !== undefined && end !== undefined && - fileLink.test(node.value) && + parseMarkdownFileLink(inlineCodeFilePathCandidate(node.value) ?? "") !== null && !/[<>]/.test(node.value) ) edits.push({ diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx index abc9cdfa8757b..9daccc9d22bfb 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -28,7 +28,6 @@ import { useEnrichedLinkVariants } from "./useEnrichedLinkVariants"; import { useRetryingImageSource } from "./useRetryingImageSource"; import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; import { - ENRICHED_INLINE_FILE_LINK_REGEX, enrichedSkillLinkRegex, enrichedLinkVariantPattern, } from "../lib/enrichedLinkPresentation"; @@ -161,14 +160,11 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { const { markdown, fileHrefs } = useMemo(() => { if (!fileEmbedLabel) { return { - markdown: markdownForNativeRendering(props.markdown, ENRICHED_INLINE_FILE_LINK_REGEX), + markdown: markdownForNativeRendering(props.markdown), fileHrefs: new Set(), }; } - const content = markdownWithInlineFileLinks( - markdownWithHtml(props.markdown), - ENRICHED_INLINE_FILE_LINK_REGEX, - ); + const content = markdownWithInlineFileLinks(markdownWithHtml(props.markdown)); const files = markdownWithFileEmbeds(content, fileEmbedLabel); return { ...files, markdown: markdownWithTableBreaks(files.markdown) }; }, [props.markdown, fileEmbedLabel]);