From 8ba692c1e50bcf2cccf47fe15710cafb0c43ce34 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 8 Jun 2026 16:03:40 +0300 Subject: [PATCH 01/53] feat(desktop): make file paths in chat messages clickable Add remark plugin (linkifyPaths) that detects Unix, tilde, and Windows file paths in markdown text and converts them to open-file:// links. Clicking a path calls shell.showItemInFolder() to reveal the file in Finder/Explorer. Signed-off-by: Denis --- ui/desktop/src/components/MarkdownContent.tsx | 20 +++- ui/desktop/src/main.ts | 10 ++ ui/desktop/src/preload.ts | 3 + ui/desktop/src/styles/main.css | 12 ++ ui/desktop/src/utils/linkifyPaths.test.ts | 104 ++++++++++++++++++ ui/desktop/src/utils/linkifyPaths.ts | 77 +++++++++++++ ui/desktop/src/utils/urlSecurity.ts | 1 + 7 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 ui/desktop/src/utils/linkifyPaths.test.ts create mode 100644 ui/desktop/src/utils/linkifyPaths.ts diff --git a/ui/desktop/src/components/MarkdownContent.tsx b/ui/desktop/src/components/MarkdownContent.tsx index 76a031a43d5f..eace7961889e 100644 --- a/ui/desktop/src/components/MarkdownContent.tsx +++ b/ui/desktop/src/components/MarkdownContent.tsx @@ -29,6 +29,7 @@ const customOneDarkTheme = { import { Check, Copy } from './icons'; import { wrapHTMLInCodeBlock } from '../utils/htmlSecurity'; import { isProtocolSafe, getProtocol, BLOCKED_PROTOCOLS } from '../utils/urlSecurity'; +import { remarkLinkifyPaths, OPEN_FILE_PROTOCOL } from '../utils/linkifyPaths'; import { ConfirmationModal } from './ui/ConfirmationModal'; import { defineMessages, useIntl } from '../i18n'; @@ -258,7 +259,7 @@ const MarkdownContent = memo(function MarkdownContent({ > { + const href = props.href; + if (href && href.startsWith(OPEN_FILE_PROTOCOL)) { + const filePath = decodeURIComponent(href.slice(OPEN_FILE_PROTOCOL.length)); + return ( + { + e.preventDefault(); + e.stopPropagation(); + window.electron.openPathInExplorer(filePath); + }} + className="file-path-link" + title={`Show in Finder: ${filePath}`} + /> + ); + } return ( { + try { + shell.showItemInFolder(path); + return true; + } catch (error) { + console.error('Error showing path in explorer:', error); + return false; + } + }); + ipcMain.handle('launch-app', async (event, gooseApp: GooseApp) => { try { const launchingWindow = BrowserWindow.fromWebContents(event.sender); diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index eca1e0ea0fe3..e444d123b7d9 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -186,6 +186,7 @@ type ElectronAPI = { hasAcceptedRecipeBefore: (recipe: Recipe) => Promise; recordRecipeHash: (recipe: Recipe) => Promise; openDirectoryInExplorer: (directoryPath: string) => Promise; + openPathInExplorer: (filePath: string) => Promise; launchApp: (app: GooseApp) => Promise; refreshApp: (app: GooseApp) => Promise; closeApp: (appName: string) => Promise; @@ -344,6 +345,8 @@ const electronAPI: ElectronAPI = { recordRecipeHash: (recipe: Recipe) => ipcRenderer.invoke('record-recipe-hash', recipe), openDirectoryInExplorer: (directoryPath: string) => ipcRenderer.invoke('open-directory-in-explorer', directoryPath), + openPathInExplorer: (filePath: string) => + ipcRenderer.invoke('open-path-in-explorer', filePath), launchApp: (app: GooseApp) => ipcRenderer.invoke('launch-app', app), refreshApp: (app: GooseApp) => ipcRenderer.invoke('refresh-app', app), closeApp: (appName: string) => ipcRenderer.invoke('close-app', appName), diff --git a/ui/desktop/src/styles/main.css b/ui/desktop/src/styles/main.css index 65761549b093..89d5384e8d7e 100644 --- a/ui/desktop/src/styles/main.css +++ b/ui/desktop/src/styles/main.css @@ -581,6 +581,18 @@ pre:has(> code.bg-inline-code) { content: ''; } +a.file-path-link { + color: inherit; + text-decoration: none; + cursor: pointer; + border-bottom: 1px dashed currentColor; +} + +a.file-path-link:hover { + border-bottom-style: solid; + opacity: 0.8; +} + .user-message p { margin-bottom: 0 !important; } diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts new file mode 100644 index 000000000000..530c355e7002 --- /dev/null +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { OPEN_FILE_PROTOCOL } from './linkifyPaths'; + +const UNIX_PATH_RE = /(?:^|[\s('"`\[(,;]|\/\*.*?\*\/)()(\/(?:[a-zA-Z0-9._+-]+\/){1,}[a-zA-Z0-9._+-]+)/g; +const TILDE_PATH_RE = /(?:^|[\s('"`\[(,;]|\/\*.*?\*\/)()(~\/(?:[a-zA-Z0-9._+-]+\/)*[a-zA-Z0-9._+-]+)/g; +const WIN_PATH_RE = /(?:^|[\s('"`\[(,;]|\/\*.*?\*\/)()([A-Za-z]:[\\/](?:[a-zA-Z0-9._+-]+[\\/])+[a-zA-Z0-9._+-]+)/g; + +type PathMatch = [index: number, path: string]; + +function findPaths(text: string): PathMatch[] { + const matches: PathMatch[] = []; + for (const re of [UNIX_PATH_RE, TILDE_PATH_RE, WIN_PATH_RE]) { + let m: RegExpExecArray | null; + const localRe = new RegExp(re.source, 'g'); + while ((m = localRe.exec(text)) !== null) { + const prefixLen = m[1].length; + const path = m[2]; + const index = m.index + prefixLen; + matches.push([index, path]); + } + } + matches.sort((a, b) => a[0] - b[0]); + const result: PathMatch[] = []; + let lastEnd = 0; + for (const [index, path] of matches) { + if (index < lastEnd) continue; + result.push([index, path]); + lastEnd = index + path.length; + } + return result; +} + +describe('path linkification', () => { + describe('Unix absolute paths', () => { + it('detects Unix absolute paths', () => { + const matches = findPaths('Created file at /home/user/project/src/main.rs'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/home/user/project/src/main.rs'); + }); + + it('does not match single-segment paths', () => { + const matches = findPaths('Go to / for root'); + expect(matches).toHaveLength(0); + }); + + it('detects multiple paths in one line', () => { + const matches = findPaths('Compare /etc/hosts and /etc/resolv.conf'); + expect(matches).toHaveLength(2); + expect(matches[0][1]).toBe('/etc/hosts'); + expect(matches[1][1]).toBe('/etc/resolv.conf'); + }); + }); + + describe('Tilde paths', () => { + it('detects tilde paths', () => { + const matches = findPaths('Config at ~/.config/app/settings.json'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('~/.config/app/settings.json'); + }); + + it('does not match bare tilde', () => { + const matches = findPaths('Go to ~ for home'); + expect(matches).toHaveLength(0); + }); + }); + + describe('Windows paths', () => { + it('detects Windows paths', () => { + const matches = findPaths('File at C:\\Users\\dev\\project\\index.ts'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('C:\\Users\\dev\\project\\index.ts'); + }); + }); + + describe('Edge cases', () => { + it('detects paths after punctuation', () => { + const matches = findPaths('Saved to "/var/log/app.log"'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/var/log/app.log'); + }); + + it('detects paths with dots and underscores', () => { + const matches = findPaths('Read /home/user/.env.local'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/home/user/.env.local'); + }); + + it('does not match URLs', () => { + const matches = findPaths('Visit https://example.com/page for info'); + expect(matches).toHaveLength(0); + }); + + it('generates correct open-file URLs', () => { + const path = '/home/user/project/src/main.rs'; + expect(OPEN_FILE_PROTOCOL + path).toBe('open-file:///home/user/project/src/main.rs'); + }); + + it('handles paths with hyphens', () => { + const matches = findPaths('Check /usr/local/lib/my-app/config.yaml'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/usr/local/lib/my-app/config.yaml'); + }); + }); +}); \ No newline at end of file diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts new file mode 100644 index 000000000000..1e603d8d4ca9 --- /dev/null +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -0,0 +1,77 @@ +import { visit } from 'unist-util-visit'; +import type { Plugin } from 'unified'; +import type { Root, Text, Link, Parent } from 'mdast'; + +const OPEN_FILE_PROTOCOL = 'open-file://'; + +const UNIX_PATH_RE = /(?:^|[\s('"`\[(,;]|\/\*.*?\*\/)()(\/(?:[a-zA-Z0-9._+-]+\/){1,}[a-zA-Z0-9._+-]+)/g; +const TILDE_PATH_RE = /(?:^|[\s('"`\[(,;]|\/\*.*?\*\/)()(~\/(?:[a-zA-Z0-9._+-]+\/)*[a-zA-Z0-9._+-]+)/g; +const WIN_PATH_RE = /(?:^|[\s('"`\[(,;]|\/\*.*?\*\/)()([A-Za-z]:[\\/](?:[a-zA-Z0-9._+-]+[\\/])+[a-zA-Z0-9._+-]+)/g; + +type PathMatch = [index: number, path: string]; + +function findPaths(text: string): PathMatch[] { + const matches: PathMatch[] = []; + for (const re of [UNIX_PATH_RE, TILDE_PATH_RE, WIN_PATH_RE]) { + let m: RegExpExecArray | null; + const localRe = new RegExp(re.source, 'g'); + while ((m = localRe.exec(text)) !== null) { + const prefixLen = m[1].length; + const path = m[2]; + const index = m.index + prefixLen; + matches.push([index, path]); + } + } + matches.sort((a, b) => a[0] - b[0]); + const result: PathMatch[] = []; + let lastEnd = 0; + for (const [index, path] of matches) { + if (index < lastEnd) continue; + result.push([index, path]); + lastEnd = index + path.length; + } + return result; +} + +export const remarkLinkifyPaths: Plugin<[], Root> = function () { + return (tree: Root) => { + visit(tree, 'text', (node: Text, index: number | undefined, parent: Parent | undefined) => { + if (index === undefined || !parent) return; + + const paths = findPaths(node.value); + if (paths.length === 0) return; + + const newNodes: Array = []; + let lastIndex = 0; + for (const [pathIndex, path] of paths) { + if (pathIndex > lastIndex) { + newNodes.push({ + type: 'text', + value: node.value.slice(lastIndex, pathIndex), + }); + } + newNodes.push({ + type: 'link', + url: OPEN_FILE_PROTOCOL + path, + title: null, + children: [ + { + type: 'text', + value: path, + }, + ], + }); + lastIndex = pathIndex + path.length; + } + if (lastIndex < node.value.length) { + newNodes.push({ + type: 'text', + value: node.value.slice(lastIndex), + }); + } + parent.children.splice(index, 1, ...newNodes); + }); + }; +}; + +export { OPEN_FILE_PROTOCOL }; \ No newline at end of file diff --git a/ui/desktop/src/utils/urlSecurity.ts b/ui/desktop/src/utils/urlSecurity.ts index 08fbb35f439c..13fe4fe8af90 100644 --- a/ui/desktop/src/utils/urlSecurity.ts +++ b/ui/desktop/src/utils/urlSecurity.ts @@ -64,6 +64,7 @@ export const SAFE_PROTOCOLS = [ 'firefox:', 'safari:', 'goose:', + 'open-file:', ]; /** From bb0a32e37fa536d7bea949cd23e28524427f07b8 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 8 Jun 2026 18:53:15 +0300 Subject: [PATCH 02/53] feat(desktop): linkify file paths in chat messages Detect Unix, tilde, and Windows file paths in chat text and inline code nodes, and render them as open-file:// links so users can click through to the referenced file. Signed-off-by: Denis --- ui/desktop/src/utils/linkifyPaths.test.ts | 17 ++++- ui/desktop/src/utils/linkifyPaths.ts | 82 +++++++++++++---------- 2 files changed, 59 insertions(+), 40 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 530c355e7002..806613d42fe7 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect } from 'vitest'; import { OPEN_FILE_PROTOCOL } from './linkifyPaths'; -const UNIX_PATH_RE = /(?:^|[\s('"`\[(,;]|\/\*.*?\*\/)()(\/(?:[a-zA-Z0-9._+-]+\/){1,}[a-zA-Z0-9._+-]+)/g; -const TILDE_PATH_RE = /(?:^|[\s('"`\[(,;]|\/\*.*?\*\/)()(~\/(?:[a-zA-Z0-9._+-]+\/)*[a-zA-Z0-9._+-]+)/g; -const WIN_PATH_RE = /(?:^|[\s('"`\[(,;]|\/\*.*?\*\/)()([A-Za-z]:[\\/](?:[a-zA-Z0-9._+-]+[\\/])+[a-zA-Z0-9._+-]+)/g; +const UNIX_PATH_RE = /(?:^|[\s('"`[(,;]|\/\*.*?\*\/)?(\/(?:[a-zA-Z0-9._+-]+\/){1,}[a-zA-Z0-9._+-]+)/g; +const TILDE_PATH_RE = /(?:^|[\s('"`[(,;]|\/\*.*?\*\/)?(~\/(?:[a-zA-Z0-9._+-]+\/)*[a-zA-Z0-9._+-]+)/g; +const WIN_PATH_RE = /(?:^|[\s('"`[(,;]|\/\*.*?\*\/)?([A-Za-z]:[\\/](?:[a-zA-Z0-9._+-]+[\\/])+[a-zA-Z0-9._+-]+)/g; type PathMatch = [index: number, path: string]; @@ -100,5 +100,16 @@ describe('path linkification', () => { expect(matches).toHaveLength(1); expect(matches[0][1]).toBe('/usr/local/lib/my-app/config.yaml'); }); + + it('does not match content inside code blocks', () => { + const matches = findPaths('Use `Array` for generics'); + expect(matches).toHaveLength(0); + }); + + it('matches paths inside inline code markers', () => { + const matches = findPaths('`/usr/local/bin/node`'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/usr/local/bin/node'); + }); }); }); \ No newline at end of file diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 1e603d8d4ca9..089c6d75b70e 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -1,12 +1,12 @@ import { visit } from 'unist-util-visit'; import type { Plugin } from 'unified'; -import type { Root, Text, Link, Parent } from 'mdast'; +import type { Root, Text, InlineCode, Link, Parent } from 'mdast'; const OPEN_FILE_PROTOCOL = 'open-file://'; -const UNIX_PATH_RE = /(?:^|[\s('"`\[(,;]|\/\*.*?\*\/)()(\/(?:[a-zA-Z0-9._+-]+\/){1,}[a-zA-Z0-9._+-]+)/g; -const TILDE_PATH_RE = /(?:^|[\s('"`\[(,;]|\/\*.*?\*\/)()(~\/(?:[a-zA-Z0-9._+-]+\/)*[a-zA-Z0-9._+-]+)/g; -const WIN_PATH_RE = /(?:^|[\s('"`\[(,;]|\/\*.*?\*\/)()([A-Za-z]:[\\/](?:[a-zA-Z0-9._+-]+[\\/])+[a-zA-Z0-9._+-]+)/g; +const UNIX_PATH_RE = /(?:^|[\s('"`[(,;]|\/\*.*?\*\/)?(\/(?:[a-zA-Z0-9._+-]+\/){1,}[a-zA-Z0-9._+-]+)/g; +const TILDE_PATH_RE = /(?:^|[\s('"`[(,;]|\/\*.*?\*\/)?(~\/(?:[a-zA-Z0-9._+-]+\/)*[a-zA-Z0-9._+-]+)/g; +const WIN_PATH_RE = /(?:^|[\s('"`[(,;]|\/\*.*?\*\/)?([A-Za-z]:[\\/](?:[a-zA-Z0-9._+-]+[\\/])+[a-zA-Z0-9._+-]+)/g; type PathMatch = [index: number, path: string]; @@ -33,43 +33,51 @@ function findPaths(text: string): PathMatch[] { return result; } +function linkifyNode(node: Text | InlineCode, index: number, parent: Parent): void { + const text = node.value; + const paths = findPaths(text); + if (paths.length === 0) return; + + const newNodes: Array = []; + let lastIndex = 0; + for (const [pathIndex, path] of paths) { + if (pathIndex > lastIndex) { + newNodes.push({ + type: node.type, + value: text.slice(lastIndex, pathIndex), + } as Text | InlineCode); + } + newNodes.push({ + type: 'link', + url: OPEN_FILE_PROTOCOL + path, + title: null, + children: [ + { + type: 'text', + value: path, + }, + ], + }); + lastIndex = pathIndex + path.length; + } + if (lastIndex < text.length) { + newNodes.push({ + type: node.type, + value: text.slice(lastIndex), + } as Text | InlineCode); + } + parent.children.splice(index, 1, ...newNodes); +} + export const remarkLinkifyPaths: Plugin<[], Root> = function () { return (tree: Root) => { visit(tree, 'text', (node: Text, index: number | undefined, parent: Parent | undefined) => { if (index === undefined || !parent) return; - - const paths = findPaths(node.value); - if (paths.length === 0) return; - - const newNodes: Array = []; - let lastIndex = 0; - for (const [pathIndex, path] of paths) { - if (pathIndex > lastIndex) { - newNodes.push({ - type: 'text', - value: node.value.slice(lastIndex, pathIndex), - }); - } - newNodes.push({ - type: 'link', - url: OPEN_FILE_PROTOCOL + path, - title: null, - children: [ - { - type: 'text', - value: path, - }, - ], - }); - lastIndex = pathIndex + path.length; - } - if (lastIndex < node.value.length) { - newNodes.push({ - type: 'text', - value: node.value.slice(lastIndex), - }); - } - parent.children.splice(index, 1, ...newNodes); + linkifyNode(node, index, parent); + }); + visit(tree, 'inlineCode', (node: InlineCode, index: number | undefined, parent: Parent | undefined) => { + if (index === undefined || !parent) return; + linkifyNode(node, index, parent); }); }; }; From e310220557f57eabf935a982434f44c16797a4b3 Mon Sep 17 00:00:00 2001 From: Denis Date: Thu, 18 Jun 2026 08:50:46 +0300 Subject: [PATCH 03/53] fix(desktop): address review feedback for clickable file paths Fix regex capture group indexing in findPaths, strip trailing sentence punctuation, skip URL-like matches, and avoid re-linkifying text inside links. Test the exported findPaths implementation and expand tilde paths before revealing files in the system file manager. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/main.ts | 2 +- ui/desktop/src/utils/linkifyPaths.test.ts | 39 +++++------------------ ui/desktop/src/utils/linkifyPaths.ts | 25 +++++++++++---- 3 files changed, 28 insertions(+), 38 deletions(-) diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index edb7e57d8b0f..5982de73a5f9 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -2781,7 +2781,7 @@ async function appMain() { ipcMain.handle('open-path-in-explorer', async (_event, path: string) => { try { - shell.showItemInFolder(path); + shell.showItemInFolder(expandTilde(path)); return true; } catch (error) { console.error('Error showing path in explorer:', error); diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 806613d42fe7..b218dd33c99b 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -1,34 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { OPEN_FILE_PROTOCOL } from './linkifyPaths'; - -const UNIX_PATH_RE = /(?:^|[\s('"`[(,;]|\/\*.*?\*\/)?(\/(?:[a-zA-Z0-9._+-]+\/){1,}[a-zA-Z0-9._+-]+)/g; -const TILDE_PATH_RE = /(?:^|[\s('"`[(,;]|\/\*.*?\*\/)?(~\/(?:[a-zA-Z0-9._+-]+\/)*[a-zA-Z0-9._+-]+)/g; -const WIN_PATH_RE = /(?:^|[\s('"`[(,;]|\/\*.*?\*\/)?([A-Za-z]:[\\/](?:[a-zA-Z0-9._+-]+[\\/])+[a-zA-Z0-9._+-]+)/g; - -type PathMatch = [index: number, path: string]; - -function findPaths(text: string): PathMatch[] { - const matches: PathMatch[] = []; - for (const re of [UNIX_PATH_RE, TILDE_PATH_RE, WIN_PATH_RE]) { - let m: RegExpExecArray | null; - const localRe = new RegExp(re.source, 'g'); - while ((m = localRe.exec(text)) !== null) { - const prefixLen = m[1].length; - const path = m[2]; - const index = m.index + prefixLen; - matches.push([index, path]); - } - } - matches.sort((a, b) => a[0] - b[0]); - const result: PathMatch[] = []; - let lastEnd = 0; - for (const [index, path] of matches) { - if (index < lastEnd) continue; - result.push([index, path]); - lastEnd = index + path.length; - } - return result; -} +import { findPaths, OPEN_FILE_PROTOCOL } from './linkifyPaths'; describe('path linkification', () => { describe('Unix absolute paths', () => { @@ -85,6 +56,12 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/home/user/.env.local'); }); + it('strips trailing sentence punctuation', () => { + const matches = findPaths('Created /tmp/result.txt.'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/tmp/result.txt'); + }); + it('does not match URLs', () => { const matches = findPaths('Visit https://example.com/page for info'); expect(matches).toHaveLength(0); @@ -112,4 +89,4 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/usr/local/bin/node'); }); }); -}); \ No newline at end of file +}); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 089c6d75b70e..f8aab97074a6 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -8,17 +8,30 @@ const UNIX_PATH_RE = /(?:^|[\s('"`[(,;]|\/\*.*?\*\/)?(\/(?:[a-zA-Z0-9._+-]+\/){1 const TILDE_PATH_RE = /(?:^|[\s('"`[(,;]|\/\*.*?\*\/)?(~\/(?:[a-zA-Z0-9._+-]+\/)*[a-zA-Z0-9._+-]+)/g; const WIN_PATH_RE = /(?:^|[\s('"`[(,;]|\/\*.*?\*\/)?([A-Za-z]:[\\/](?:[a-zA-Z0-9._+-]+[\\/])+[a-zA-Z0-9._+-]+)/g; +const TRAILING_PUNCTUATION_RE = /[.,;:!?)'\]"]+$/; + type PathMatch = [index: number, path: string]; -function findPaths(text: string): PathMatch[] { +function stripTrailingPunctuation(path: string): string { + return path.replace(TRAILING_PUNCTUATION_RE, ''); +} + +function isUrlPath(text: string, index: number): boolean { + const before = text.slice(0, index); + return /[a-zA-Z][a-zA-Z0-9+.-]*:\/\/?$/.test(before); +} + +export function findPaths(text: string): PathMatch[] { const matches: PathMatch[] = []; for (const re of [UNIX_PATH_RE, TILDE_PATH_RE, WIN_PATH_RE]) { let m: RegExpExecArray | null; const localRe = new RegExp(re.source, 'g'); while ((m = localRe.exec(text)) !== null) { - const prefixLen = m[1].length; - const path = m[2]; + const path = stripTrailingPunctuation(m[1]); + if (path.length === 0) continue; + const prefixLen = m[0].length - m[1].length; const index = m.index + prefixLen; + if (isUrlPath(text, index)) continue; matches.push([index, path]); } } @@ -72,14 +85,14 @@ function linkifyNode(node: Text | InlineCode, index: number, parent: Parent): vo export const remarkLinkifyPaths: Plugin<[], Root> = function () { return (tree: Root) => { visit(tree, 'text', (node: Text, index: number | undefined, parent: Parent | undefined) => { - if (index === undefined || !parent) return; + if (index === undefined || !parent || parent.type === 'link') return; linkifyNode(node, index, parent); }); visit(tree, 'inlineCode', (node: InlineCode, index: number | undefined, parent: Parent | undefined) => { - if (index === undefined || !parent) return; + if (index === undefined || !parent || parent.type === 'link') return; linkifyNode(node, index, parent); }); }; }; -export { OPEN_FILE_PROTOCOL }; \ No newline at end of file +export { OPEN_FILE_PROTOCOL }; From 8ed41e8fec844c79e8fb738eeced77c26cd19dbe Mon Sep 17 00:00:00 2001 From: Denis Date: Thu, 18 Jun 2026 16:43:20 +0300 Subject: [PATCH 04/53] fix(desktop): support file paths with spaces in linkifier Replace greedy regex matching with a path parser that allows spaces inside segment names while stopping before trailing prose. Encode spaces in open-file:// URLs so markdown hrefs remain valid. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 29 ++++- ui/desktop/src/utils/linkifyPaths.ts | 136 ++++++++++++++++++---- 2 files changed, 140 insertions(+), 25 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index b218dd33c99b..c64fb109879c 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -20,6 +20,18 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/etc/hosts'); expect(matches[1][1]).toBe('/etc/resolv.conf'); }); + + it('detects paths with spaces in segment names', () => { + const matches = findPaths('Saved /Users/me/My Project/result.txt for review'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/Users/me/My Project/result.txt'); + }); + + it('detects lowercase folder names with spaces at end of path', () => { + const matches = findPaths('Output in /home/user/my documents'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/home/user/my documents'); + }); }); describe('Tilde paths', () => { @@ -41,6 +53,12 @@ describe('path linkification', () => { expect(matches).toHaveLength(1); expect(matches[0][1]).toBe('C:\\Users\\dev\\project\\index.ts'); }); + + it('detects Windows paths with spaces in segment names', () => { + const matches = findPaths('File at C:\\Users\\dev\\My Project\\index.ts'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('C:\\Users\\dev\\My Project\\index.ts'); + }); }); describe('Edge cases', () => { @@ -69,7 +87,16 @@ describe('path linkification', () => { it('generates correct open-file URLs', () => { const path = '/home/user/project/src/main.rs'; - expect(OPEN_FILE_PROTOCOL + path).toBe('open-file:///home/user/project/src/main.rs'); + expect(OPEN_FILE_PROTOCOL + encodeURI(path)).toBe( + 'open-file:///home/user/project/src/main.rs' + ); + }); + + it('encodes spaces in open-file URLs', () => { + const path = '/Users/me/My Project/result.txt'; + expect(OPEN_FILE_PROTOCOL + encodeURI(path)).toBe( + 'open-file:///Users/me/My%20Project/result.txt' + ); }); it('handles paths with hyphens', () => { diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index f8aab97074a6..452a9721b3b8 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -4,13 +4,14 @@ import type { Root, Text, InlineCode, Link, Parent } from 'mdast'; const OPEN_FILE_PROTOCOL = 'open-file://'; -const UNIX_PATH_RE = /(?:^|[\s('"`[(,;]|\/\*.*?\*\/)?(\/(?:[a-zA-Z0-9._+-]+\/){1,}[a-zA-Z0-9._+-]+)/g; -const TILDE_PATH_RE = /(?:^|[\s('"`[(,;]|\/\*.*?\*\/)?(~\/(?:[a-zA-Z0-9._+-]+\/)*[a-zA-Z0-9._+-]+)/g; -const WIN_PATH_RE = /(?:^|[\s('"`[(,;]|\/\*.*?\*\/)?([A-Za-z]:[\\/](?:[a-zA-Z0-9._+-]+[\\/])+[a-zA-Z0-9._+-]+)/g; - const TRAILING_PUNCTUATION_RE = /[.,;:!?)'\]"]+$/; type PathMatch = [index: number, path: string]; +type Separator = '/' | '\\'; + +function isPathChar(char: string): boolean { + return /[a-zA-Z0-9._+-]/.test(char); +} function stripTrailingPunctuation(path: string): string { return path.replace(TRAILING_PUNCTUATION_RE, ''); @@ -21,29 +22,116 @@ function isUrlPath(text: string, index: number): boolean { return /[a-zA-Z][a-zA-Z0-9+.-]*:\/\/?$/.test(before); } +function isValidPathStart(text: string, index: number): boolean { + if (index === 0) return true; + if (isUrlPath(text, index)) return false; + const prev = text[index - 1]; + if (/[\s('"`[(,;]/.test(prev)) return true; + const before = text.slice(0, index); + return /\/\*.*?\*\/$/.test(before); +} + +function readSpacedContinuation(text: string, spaceIndex: number, separator: Separator): number { + if (text[spaceIndex] !== ' ') return spaceIndex; + + let j = spaceIndex + 1; + while (j < text.length && isPathChar(text[j])) { + j++; + } + if (j === spaceIndex + 1) return spaceIndex; + + const after = text[j]; + if (after === separator) return j; + if (after === undefined || /[.,;:!?)'\]"]/.test(after)) return j; + + if (after === ' ') { + const rest = text.slice(j).trimStart(); + if (rest.startsWith(separator)) return spaceIndex; + + const word = text.slice(spaceIndex + 1, j); + if (/[A-Z0-9._+-]/.test(word)) return j; + + if (/^[a-z][a-z0-9]*$/.test(word) && (rest === '' || /^[.,;:!?)'\]"]/.test(rest))) { + return j; + } + return spaceIndex; + } + + return spaceIndex; +} + +function readSegment(text: string, start: number, separator: Separator): { end: number } | null { + let i = start; + if (i >= text.length || !isPathChar(text[i])) return null; + + while (i < text.length && isPathChar(text[i])) { + i++; + } + + while (i < text.length && text[i] === ' ') { + const continuationEnd = readSpacedContinuation(text, i, separator); + if (continuationEnd === i) break; + i = continuationEnd; + } + + return i > start ? { end: i } : null; +} + +function tryParsePath(text: string, index: number): PathMatch | null { + if (!isValidPathStart(text, index)) return null; + + let i = index; + let separator: Separator = '/'; + let minSegments: number; + + if (text[i] === '/') { + i++; + minSegments = 2; + } else if (text[i] === '~' && text[i + 1] === '/') { + i += 2; + minSegments = 1; + } else if (/[A-Za-z]/.test(text[i] ?? '') && text[i + 1] === ':') { + i += 2; + if (text[i] === '/' || text[i] === '\\') { + separator = text[i] as Separator; + i++; + } + minSegments = 2; + } else { + return null; + } + + let segmentCount = 0; + while (i < text.length) { + const segment = readSegment(text, i, separator); + if (!segment) break; + i = segment.end; + segmentCount++; + if (i < text.length && text[i] === separator) { + i++; + continue; + } + break; + } + + if (segmentCount < minSegments) return null; + + const path = stripTrailingPunctuation(text.slice(index, i)); + if (path.length === 0) return null; + + return [index, path]; +} + export function findPaths(text: string): PathMatch[] { const matches: PathMatch[] = []; - for (const re of [UNIX_PATH_RE, TILDE_PATH_RE, WIN_PATH_RE]) { - let m: RegExpExecArray | null; - const localRe = new RegExp(re.source, 'g'); - while ((m = localRe.exec(text)) !== null) { - const path = stripTrailingPunctuation(m[1]); - if (path.length === 0) continue; - const prefixLen = m[0].length - m[1].length; - const index = m.index + prefixLen; - if (isUrlPath(text, index)) continue; - matches.push([index, path]); + for (let i = 0; i < text.length; i++) { + const match = tryParsePath(text, i); + if (match) { + matches.push(match); + i = match[0] + match[1].length - 1; } } - matches.sort((a, b) => a[0] - b[0]); - const result: PathMatch[] = []; - let lastEnd = 0; - for (const [index, path] of matches) { - if (index < lastEnd) continue; - result.push([index, path]); - lastEnd = index + path.length; - } - return result; + return matches; } function linkifyNode(node: Text | InlineCode, index: number, parent: Parent): void { @@ -62,7 +150,7 @@ function linkifyNode(node: Text | InlineCode, index: number, parent: Parent): vo } newNodes.push({ type: 'link', - url: OPEN_FILE_PROTOCOL + path, + url: OPEN_FILE_PROTOCOL + encodeURI(path), title: null, children: [ { From 692f479c4e8abd984fdbd858decf86290d0fbaf7 Mon Sep 17 00:00:00 2001 From: Denis Date: Thu, 18 Jun 2026 16:53:01 +0300 Subject: [PATCH 05/53] fix(desktop): exclude trailing prose from spaced path segments Do not treat lowercase words after a complete path segment as part of the path when they precede sentence punctuation. Also strip trailing punctuation while scanning spaced continuations so dots are not absorbed into the word via isPathChar. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 12 +++++++ ui/desktop/src/utils/linkifyPaths.ts | 42 +++++++++++++++++------ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index c64fb109879c..3731d60d37b3 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -80,6 +80,18 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/tmp/result.txt'); }); + it('does not include trailing prose before sentence punctuation', () => { + const matches = findPaths('Created /tmp/result successfully.'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/tmp/result'); + }); + + it('includes spaced folder names before sentence punctuation', () => { + const matches = findPaths('Saved to /home/user/my documents.'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/home/user/my documents'); + }); + it('does not match URLs', () => { const matches = findPaths('Visit https://example.com/page for info'); expect(matches).toHaveLength(0); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 452a9721b3b8..a175d511e143 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -31,30 +31,50 @@ function isValidPathStart(text: string, index: number): boolean { return /\/\*.*?\*\/$/.test(before); } -function readSpacedContinuation(text: string, spaceIndex: number, separator: Separator): number { +function getLastToken(text: string, segmentStart: number, beforeIndex: number): string { + const segment = text.slice(segmentStart, beforeIndex); + const lastSpace = segment.lastIndexOf(' '); + return lastSpace === -1 ? segment : segment.slice(lastSpace + 1); +} + +function isPathLikeSpacedWord(word: string, prevToken: string): boolean { + if (/[A-Z0-9._+-]/.test(word)) return true; + return ( + /^[a-z][a-z0-9]*$/.test(word) && prevToken.length <= 3 && !prevToken.includes('.') + ); +} + +function readSpacedContinuation( + text: string, + spaceIndex: number, + segmentStart: number, + separator: Separator +): number { if (text[spaceIndex] !== ' ') return spaceIndex; let j = spaceIndex + 1; while (j < text.length && isPathChar(text[j])) { j++; } + while (j > spaceIndex + 1 && /[.,;:!?)'\]"]/.test(text[j - 1] ?? '')) { + j--; + } if (j === spaceIndex + 1) return spaceIndex; + const word = text.slice(spaceIndex + 1, j); + const prevToken = getLastToken(text, segmentStart, spaceIndex); const after = text[j]; + if (after === separator) return j; - if (after === undefined || /[.,;:!?)'\]"]/.test(after)) return j; + + if (after === undefined || /[.,;:!?)'\]"]/.test(after)) { + return isPathLikeSpacedWord(word, prevToken) ? j : spaceIndex; + } if (after === ' ') { const rest = text.slice(j).trimStart(); if (rest.startsWith(separator)) return spaceIndex; - - const word = text.slice(spaceIndex + 1, j); - if (/[A-Z0-9._+-]/.test(word)) return j; - - if (/^[a-z][a-z0-9]*$/.test(word) && (rest === '' || /^[.,;:!?)'\]"]/.test(rest))) { - return j; - } - return spaceIndex; + return isPathLikeSpacedWord(word, prevToken) ? j : spaceIndex; } return spaceIndex; @@ -69,7 +89,7 @@ function readSegment(text: string, start: number, separator: Separator): { end: } while (i < text.length && text[i] === ' ') { - const continuationEnd = readSpacedContinuation(text, i, separator); + const continuationEnd = readSpacedContinuation(text, i, start, separator); if (continuationEnd === i) break; i = continuationEnd; } From 18923abb4e42b76ed95443858bbf1ff2a5c29a62 Mon Sep 17 00:00:00 2001 From: Denis Date: Thu, 18 Jun 2026 18:08:11 +0300 Subject: [PATCH 06/53] fix(desktop): make path linkifier linear and skip nested traversal Scan text in a single pass with O(1) URL and block-comment checks instead of re-slicing prefixes at every index. Skip visiting children of generated open-file links to avoid re-linkifying path text. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 10 +++ ui/desktop/src/utils/linkifyPaths.ts | 84 +++++++++++++++++------ 2 files changed, 74 insertions(+), 20 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 3731d60d37b3..a20bc1fc70f4 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -127,5 +127,15 @@ describe('path linkification', () => { expect(matches).toHaveLength(1); expect(matches[0][1]).toBe('/usr/local/bin/node'); }); + + it('returns no matches for long prose without paths', () => { + const prose = 'word '.repeat(10_000); + const start = performance.now(); + const matches = findPaths(prose); + const elapsed = performance.now() - start; + + expect(matches).toHaveLength(0); + expect(elapsed).toBeLessThan(500); + }); }); }); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index a175d511e143..1d5ea3b13b1e 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -1,4 +1,4 @@ -import { visit } from 'unist-util-visit'; +import { visit, SKIP } from 'unist-util-visit'; import type { Plugin } from 'unified'; import type { Root, Text, InlineCode, Link, Parent } from 'mdast'; @@ -17,18 +17,37 @@ function stripTrailingPunctuation(path: string): string { return path.replace(TRAILING_PUNCTUATION_RE, ''); } -function isUrlPath(text: string, index: number): boolean { - const before = text.slice(0, index); - return /[a-zA-Z][a-zA-Z0-9+.-]*:\/\/?$/.test(before); +function isUrlPathAt(text: string, index: number): boolean { + let i = index - 1; + if (i < 0) return false; + + if (text[i] === '/') { + i--; + if (i >= 0 && text[i] === '/') i--; + } + if (i < 0 || text[i] !== ':') return false; + + i--; + const schemeEnd = i; + while (i >= 0 && /[a-zA-Z0-9+.-]/.test(text[i])) { + i--; + } + + const scheme = text.slice(i + 1, schemeEnd + 1); + return scheme.length > 0 && /[a-zA-Z]/.test(scheme[0]); } -function isValidPathStart(text: string, index: number): boolean { +function isCandidatePathStart(text: string, index: number, afterBlockComment: boolean): boolean { if (index === 0) return true; - if (isUrlPath(text, index)) return false; + if (isUrlPathAt(text, index)) return false; const prev = text[index - 1]; if (/[\s('"`[(,;]/.test(prev)) return true; - const before = text.slice(0, index); - return /\/\*.*?\*\/$/.test(before); + return afterBlockComment; +} + +function couldStartPath(text: string, index: number): boolean { + const char = text[index]; + return char === '/' || char === '~' || /[A-Za-z]/.test(char); } function getLastToken(text: string, segmentStart: number, beforeIndex: number): string { @@ -97,9 +116,7 @@ function readSegment(text: string, start: number, separator: Separator): { end: return i > start ? { end: i } : null; } -function tryParsePath(text: string, index: number): PathMatch | null { - if (!isValidPathStart(text, index)) return null; - +function parsePathAt(text: string, index: number): PathMatch | null { let i = index; let separator: Separator = '/'; let minSegments: number; @@ -144,13 +161,35 @@ function tryParsePath(text: string, index: number): PathMatch | null { export function findPaths(text: string): PathMatch[] { const matches: PathMatch[] = []; + let afterBlockComment = false; + for (let i = 0; i < text.length; i++) { - const match = tryParsePath(text, i); - if (match) { - matches.push(match); - i = match[0] + match[1].length - 1; + if (text[i] === '/' && text[i + 1] === '*') { + i += 2; + while (i < text.length - 1 && !(text[i] === '*' && text[i + 1] === '/')) { + i++; + } + i += 1; + afterBlockComment = true; + continue; } + + if ( + couldStartPath(text, i) && + isCandidatePathStart(text, i, afterBlockComment) + ) { + const match = parsePathAt(text, i); + if (match) { + matches.push(match); + i = match[0] + match[1].length - 1; + afterBlockComment = false; + continue; + } + } + + afterBlockComment = false; } + return matches; } @@ -192,11 +231,16 @@ function linkifyNode(node: Text | InlineCode, index: number, parent: Parent): vo export const remarkLinkifyPaths: Plugin<[], Root> = function () { return (tree: Root) => { - visit(tree, 'text', (node: Text, index: number | undefined, parent: Parent | undefined) => { - if (index === undefined || !parent || parent.type === 'link') return; - linkifyNode(node, index, parent); - }); - visit(tree, 'inlineCode', (node: InlineCode, index: number | undefined, parent: Parent | undefined) => { + visit(tree, (node, index, parent) => { + if (node.type === 'link') { + const url = 'url' in node ? node.url : undefined; + if (url?.startsWith(OPEN_FILE_PROTOCOL)) { + return SKIP; + } + return; + } + + if (node.type !== 'text' && node.type !== 'inlineCode') return; if (index === undefined || !parent || parent.type === 'link') return; linkifyNode(node, index, parent); }); From dd6b368ca53600f983ba3a3c346d84e77d1d4897 Mon Sep 17 00:00:00 2001 From: Denis Date: Thu, 18 Jun 2026 18:40:54 +0300 Subject: [PATCH 07/53] fix(desktop): address Codex review and CI typecheck failure Require multi-char basenames before spaced continuations, remove open-file from global safe protocols, guard decodeURIComponent for authored links, and return explicitly from visit callback branches. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/components/MarkdownContent.tsx | 35 +++++++++++-------- ui/desktop/src/utils/linkifyPaths.test.ts | 10 ++++-- ui/desktop/src/utils/linkifyPaths.ts | 16 ++++++--- ui/desktop/src/utils/urlSecurity.ts | 1 - 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/ui/desktop/src/components/MarkdownContent.tsx b/ui/desktop/src/components/MarkdownContent.tsx index eace7961889e..11377f00cb89 100644 --- a/ui/desktop/src/components/MarkdownContent.tsx +++ b/ui/desktop/src/components/MarkdownContent.tsx @@ -274,20 +274,27 @@ const MarkdownContent = memo(function MarkdownContent({ a: (props) => { const href = props.href; if (href && href.startsWith(OPEN_FILE_PROTOCOL)) { - const filePath = decodeURIComponent(href.slice(OPEN_FILE_PROTOCOL.length)); - return ( - { - e.preventDefault(); - e.stopPropagation(); - window.electron.openPathInExplorer(filePath); - }} - className="file-path-link" - title={`Show in Finder: ${filePath}`} - /> - ); + let filePath: string | undefined; + try { + filePath = decodeURIComponent(href.slice(OPEN_FILE_PROTOCOL.length)); + } catch { + filePath = undefined; + } + if (filePath) { + return ( + { + e.preventDefault(); + e.stopPropagation(); + window.electron.openPathInExplorer(filePath); + }} + className="file-path-link" + title={`Show in Finder: ${filePath}`} + /> + ); + } } return ( { expect(matches[0][1]).toBe('/tmp/result'); }); + it('does not extend short single-char basenames with prose', () => { + const matches = findPaths('Created /tmp/a successfully.'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/tmp/a'); + }); + it('includes spaced folder names before sentence punctuation', () => { const matches = findPaths('Saved to /home/user/my documents.'); expect(matches).toHaveLength(1); @@ -130,9 +136,9 @@ describe('path linkification', () => { it('returns no matches for long prose without paths', () => { const prose = 'word '.repeat(10_000); - const start = performance.now(); + const start = Date.now(); const matches = findPaths(prose); - const elapsed = performance.now() - start; + const elapsed = Date.now() - start; expect(matches).toHaveLength(0); expect(elapsed).toBeLessThan(500); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 1d5ea3b13b1e..da5fe023b391 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -59,7 +59,10 @@ function getLastToken(text: string, segmentStart: number, beforeIndex: number): function isPathLikeSpacedWord(word: string, prevToken: string): boolean { if (/[A-Z0-9._+-]/.test(word)) return true; return ( - /^[a-z][a-z0-9]*$/.test(word) && prevToken.length <= 3 && !prevToken.includes('.') + /^[a-z][a-z0-9]*$/.test(word) && + prevToken.length >= 2 && + prevToken.length <= 3 && + !prevToken.includes('.') ); } @@ -237,12 +240,17 @@ export const remarkLinkifyPaths: Plugin<[], Root> = function () { if (url?.startsWith(OPEN_FILE_PROTOCOL)) { return SKIP; } - return; + return undefined; } - if (node.type !== 'text' && node.type !== 'inlineCode') return; - if (index === undefined || !parent || parent.type === 'link') return; + if (node.type !== 'text' && node.type !== 'inlineCode') { + return undefined; + } + if (index === undefined || !parent || parent.type === 'link') { + return undefined; + } linkifyNode(node, index, parent); + return undefined; }); }; }; diff --git a/ui/desktop/src/utils/urlSecurity.ts b/ui/desktop/src/utils/urlSecurity.ts index 13fe4fe8af90..08fbb35f439c 100644 --- a/ui/desktop/src/utils/urlSecurity.ts +++ b/ui/desktop/src/utils/urlSecurity.ts @@ -64,7 +64,6 @@ export const SAFE_PROTOCOLS = [ 'firefox:', 'safari:', 'goose:', - 'open-file:', ]; /** From fc5c4d0ed032dd23577644e12433405768072c52 Mon Sep 17 00:00:00 2001 From: Denis Date: Thu, 18 Jun 2026 19:07:33 +0300 Subject: [PATCH 08/53] fix(desktop): support Unicode paths and keyboard file links Allow Unicode letters and numbers in path segments, reject partial matches when a later segment contains unsupported characters, and restore href on file-path links so they remain keyboard accessible. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/components/MarkdownContent.tsx | 2 +- ui/desktop/src/utils/linkifyPaths.test.ts | 11 +++++++++++ ui/desktop/src/utils/linkifyPaths.ts | 16 ++++++++++++---- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/ui/desktop/src/components/MarkdownContent.tsx b/ui/desktop/src/components/MarkdownContent.tsx index 11377f00cb89..5bd0708be083 100644 --- a/ui/desktop/src/components/MarkdownContent.tsx +++ b/ui/desktop/src/components/MarkdownContent.tsx @@ -284,7 +284,7 @@ const MarkdownContent = memo(function MarkdownContent({ return ( { e.preventDefault(); e.stopPropagation(); diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 2872a436a8ef..01f7a82e916e 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -134,6 +134,17 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/usr/local/bin/node'); }); + it('detects paths with Unicode segment names', () => { + const matches = findPaths('Saved /Users/me/デスクトップ/out.txt'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/Users/me/デスクトップ/out.txt'); + }); + + it('does not linkify partial paths before unsupported characters', () => { + const matches = findPaths('See /Users/me/🎉/out.txt'); + expect(matches).toHaveLength(0); + }); + it('returns no matches for long prose without paths', () => { const prose = 'word '.repeat(10_000); const start = Date.now(); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index da5fe023b391..fad69c68686a 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -10,7 +10,8 @@ type PathMatch = [index: number, path: string]; type Separator = '/' | '\\'; function isPathChar(char: string): boolean { - return /[a-zA-Z0-9._+-]/.test(char); + if (/[a-zA-Z0-9._+-]/.test(char)) return true; + return /\p{L}|\p{N}/u.test(char); } function stripTrailingPunctuation(path: string): string { @@ -57,9 +58,8 @@ function getLastToken(text: string, segmentStart: number, beforeIndex: number): } function isPathLikeSpacedWord(word: string, prevToken: string): boolean { - if (/[A-Z0-9._+-]/.test(word)) return true; + if (!/^[a-z0-9]+$/.test(word)) return true; return ( - /^[a-z][a-z0-9]*$/.test(word) && prevToken.length >= 2 && prevToken.length <= 3 && !prevToken.includes('.') @@ -144,7 +144,15 @@ function parsePathAt(text: string, index: number): PathMatch | null { let segmentCount = 0; while (i < text.length) { const segment = readSegment(text, i, separator); - if (!segment) break; + if (!segment) { + if (segmentCount >= minSegments && i < text.length) { + const next = text[i]; + if (next !== separator && next !== ' ' && !/[.,;:!?)'\]"]/.test(next)) { + return null; + } + } + break; + } i = segment.end; segmentCount++; if (i < text.length && text[i] === separator) { From 435732b8724fdc2300a2c9f29302fffe4edb5efc Mon Sep 17 00:00:00 2001 From: Denis Date: Thu, 18 Jun 2026 19:23:36 +0300 Subject: [PATCH 09/53] fix(desktop): handle numeric path suffixes and link references Allow digit-only spaced continuations for folders like "Project 2026" and skip linkification inside linkReference parents to preserve reference-style markdown. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 45 ++++++++++++++++++++++- ui/desktop/src/utils/linkifyPaths.ts | 7 +++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 01f7a82e916e..319ca0b917cd 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { findPaths, OPEN_FILE_PROTOCOL } from './linkifyPaths'; +import type { Root } from 'mdast'; +import { findPaths, OPEN_FILE_PROTOCOL, remarkLinkifyPaths } from './linkifyPaths'; describe('path linkification', () => { describe('Unix absolute paths', () => { @@ -134,6 +135,12 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/usr/local/bin/node'); }); + it('detects paths with numeric suffixes in segment names', () => { + const matches = findPaths('Saved /Users/me/Project 2026.'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/Users/me/Project 2026'); + }); + it('detects paths with Unicode segment names', () => { const matches = findPaths('Saved /Users/me/デスクトップ/out.txt'); expect(matches).toHaveLength(1); @@ -156,3 +163,39 @@ describe('path linkification', () => { }); }); }); + +describe('remarkLinkifyPaths', () => { + it('does not linkify paths inside link references', () => { + const tree: Root = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'linkReference', + identifier: 'log', + label: 'log', + referenceType: 'full', + children: [{ type: 'text', value: 'log /tmp/out' }], + }, + ], + }, + ], + }; + + const transform = (remarkLinkifyPaths as unknown as () => (tree: Root) => void)(); + transform(tree); + + const paragraph = tree.children[0]; + expect(paragraph.type).toBe('paragraph'); + if (paragraph.type !== 'paragraph') return; + + const linkRef = paragraph.children[0]; + expect(linkRef.type).toBe('linkReference'); + if (linkRef.type !== 'linkReference') return; + + expect(linkRef.children).toHaveLength(1); + expect(linkRef.children[0]).toEqual({ type: 'text', value: 'log /tmp/out' }); + }); +}); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index fad69c68686a..a7ee4bb39cb9 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -58,6 +58,7 @@ function getLastToken(text: string, segmentStart: number, beforeIndex: number): } function isPathLikeSpacedWord(word: string, prevToken: string): boolean { + if (/^\d+$/.test(word)) return true; if (!/^[a-z0-9]+$/.test(word)) return true; return ( prevToken.length >= 2 && @@ -66,6 +67,10 @@ function isPathLikeSpacedWord(word: string, prevToken: string): boolean { ); } +function isLinkLikeParent(parent: Parent | undefined): boolean { + return parent?.type === 'link' || parent?.type === 'linkReference'; +} + function readSpacedContinuation( text: string, spaceIndex: number, @@ -254,7 +259,7 @@ export const remarkLinkifyPaths: Plugin<[], Root> = function () { if (node.type !== 'text' && node.type !== 'inlineCode') { return undefined; } - if (index === undefined || !parent || parent.type === 'link') { + if (index === undefined || !parent || isLinkLikeParent(parent)) { return undefined; } linkifyNode(node, index, parent); From f7ccad017eaae385afdbaa1f890797b5e69033b1 Mon Sep 17 00:00:00 2001 From: Denis Date: Thu, 18 Jun 2026 19:52:05 +0300 Subject: [PATCH 10/53] fix(desktop): skip link subtrees and parenthesized filenames Skip the entire link and linkReference subtrees during traversal so formatted link labels are not linkified, and include download-style suffixes like "report (1).pdf" in matched paths. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 46 +++++++++++++++++++++++ ui/desktop/src/utils/linkifyPaths.ts | 37 +++++++++++++++--- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 319ca0b917cd..e922719e6aa4 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -135,6 +135,12 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/usr/local/bin/node'); }); + it('detects paths with parenthesized download suffixes', () => { + const matches = findPaths('Saved /Users/me/Downloads/report (1).pdf'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/Users/me/Downloads/report (1).pdf'); + }); + it('detects paths with numeric suffixes in segment names', () => { const matches = findPaths('Saved /Users/me/Project 2026.'); expect(matches).toHaveLength(1); @@ -198,4 +204,44 @@ describe('remarkLinkifyPaths', () => { expect(linkRef.children).toHaveLength(1); expect(linkRef.children[0]).toEqual({ type: 'text', value: 'log /tmp/out' }); }); + + it('does not linkify paths inside formatted link labels', () => { + const tree: Root = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'link', + url: 'https://example.com', + children: [ + { + type: 'strong', + children: [{ type: 'text', value: '/tmp/out' }], + }, + ], + }, + ], + }, + ], + }; + + const transform = (remarkLinkifyPaths as unknown as () => (tree: Root) => void)(); + transform(tree); + + const paragraph = tree.children[0]; + expect(paragraph.type).toBe('paragraph'); + if (paragraph.type !== 'paragraph') return; + + const link = paragraph.children[0]; + expect(link.type).toBe('link'); + if (link.type !== 'link') return; + + expect(link.url).toBe('https://example.com'); + expect(link.children).toHaveLength(1); + expect(link.children[0].type).toBe('strong'); + if (link.children[0].type !== 'strong') return; + expect(link.children[0].children[0]).toEqual({ type: 'text', value: '/tmp/out' }); + }); }); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index a7ee4bb39cb9..06e3f0091826 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -71,6 +71,30 @@ function isLinkLikeParent(parent: Parent | undefined): boolean { return parent?.type === 'link' || parent?.type === 'linkReference'; } +function readParenthesizedSuffix(text: string, spaceIndex: number): number { + if (text[spaceIndex] !== ' ') return spaceIndex; + + let i = spaceIndex + 1; + if (text[i] !== '(') return spaceIndex; + + i++; + const contentStart = i; + while (i < text.length && text[i] !== ')') { + if (text[i] === '(') return spaceIndex; + i++; + } + if (i >= text.length) return spaceIndex; + + const content = text.slice(contentStart, i); + if (!/^\d+$/.test(content)) return spaceIndex; + + i++; + while (i < text.length && isPathChar(text[i])) { + i++; + } + return i; +} + function readSpacedContinuation( text: string, spaceIndex: number, @@ -116,6 +140,11 @@ function readSegment(text: string, start: number, separator: Separator): { end: } while (i < text.length && text[i] === ' ') { + const parenEnd = readParenthesizedSuffix(text, i); + if (parenEnd > i) { + i = parenEnd; + continue; + } const continuationEnd = readSpacedContinuation(text, i, start, separator); if (continuationEnd === i) break; i = continuationEnd; @@ -248,12 +277,8 @@ function linkifyNode(node: Text | InlineCode, index: number, parent: Parent): vo export const remarkLinkifyPaths: Plugin<[], Root> = function () { return (tree: Root) => { visit(tree, (node, index, parent) => { - if (node.type === 'link') { - const url = 'url' in node ? node.url : undefined; - if (url?.startsWith(OPEN_FILE_PROTOCOL)) { - return SKIP; - } - return undefined; + if (node.type === 'link' || node.type === 'linkReference') { + return SKIP; } if (node.type !== 'text' && node.type !== 'inlineCode') { From d75a60f6d24dfcf2b46080d7d818df13d936c518 Mon Sep 17 00:00:00 2001 From: Denis Date: Thu, 18 Jun 2026 20:21:31 +0300 Subject: [PATCH 11/53] fix(desktop): include commas in filename path segments Allow commas inside path segments when immediately followed by more path characters, so names like report,final.txt linkify correctly while comma-space prose boundaries still terminate the match. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 12 ++++++++++++ ui/desktop/src/utils/linkifyPaths.ts | 20 ++++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index e922719e6aa4..6b307a04645e 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -141,6 +141,18 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/Users/me/Downloads/report (1).pdf'); }); + it('detects paths with commas in filenames', () => { + const matches = findPaths('Saved /tmp/report,final.txt'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/tmp/report,final.txt'); + }); + + it('does not extend paths across comma-separated prose', () => { + const matches = findPaths('Created /tmp/report, then continued'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/tmp/report'); + }); + it('detects paths with numeric suffixes in segment names', () => { const matches = findPaths('Saved /Users/me/Project 2026.'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 06e3f0091826..94ab0609ca5c 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -131,12 +131,28 @@ function readSpacedContinuation( return spaceIndex; } +function isFilenamePunctuation(char: string): boolean { + return char === ','; +} + function readSegment(text: string, start: number, separator: Separator): { end: number } | null { let i = start; if (i >= text.length || !isPathChar(text[i])) return null; - while (i < text.length && isPathChar(text[i])) { - i++; + while (i < text.length) { + if (isPathChar(text[i])) { + i++; + continue; + } + if ( + isFilenamePunctuation(text[i]) && + i + 1 < text.length && + isPathChar(text[i + 1]) + ) { + i++; + continue; + } + break; } while (i < text.length && text[i] === ' ') { From 6a20648ab6eb68913b7ce9c1f01f6941f4e7b2c9 Mon Sep 17 00:00:00 2001 From: Denis Date: Thu, 18 Jun 2026 20:34:36 +0300 Subject: [PATCH 12/53] fix(desktop): preserve closing parens in path suffixes Stop stripTrailingPunctuation from removing ) and ] so download-style paths like report (1) keep their balanced suffix while sentence-ending .periods are still trimmed. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 12 ++++++++++++ ui/desktop/src/utils/linkifyPaths.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 6b307a04645e..2d0d58f3c638 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -141,6 +141,18 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/Users/me/Downloads/report (1).pdf'); }); + it('preserves closing parens in parenthesized paths without extension', () => { + const matches = findPaths('Saved /Users/me/Downloads/report (1)'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/Users/me/Downloads/report (1)'); + }); + + it('strips sentence punctuation after parenthesized paths', () => { + const matches = findPaths('Saved /Users/me/Downloads/report (1).'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/Users/me/Downloads/report (1)'); + }); + it('detects paths with commas in filenames', () => { const matches = findPaths('Saved /tmp/report,final.txt'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 94ab0609ca5c..069c5d3ec196 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -4,7 +4,7 @@ import type { Root, Text, InlineCode, Link, Parent } from 'mdast'; const OPEN_FILE_PROTOCOL = 'open-file://'; -const TRAILING_PUNCTUATION_RE = /[.,;:!?)'\]"]+$/; +const TRAILING_PUNCTUATION_RE = /[.,;:!?'"]+$/; type PathMatch = [index: number, path: string]; type Separator = '/' | '\\'; From 4fc77284395888812923dcc0e61e6aa216a6ce00 Mon Sep 17 00:00:00 2001 From: Denis Date: Thu, 18 Jun 2026 20:51:16 +0300 Subject: [PATCH 13/53] fix(desktop): reject partial paths and untrusted open-file links Reject path matches that end before unsupported filename characters, allow @ and % in segments, and only auto-open file links when the visible label matches the decoded path from generated linkify output. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/components/MarkdownContent.tsx | 25 +++++++++++++------ ui/desktop/src/utils/linkifyPaths.test.ts | 24 +++++++++++++++++- ui/desktop/src/utils/linkifyPaths.ts | 25 +++++++++++++++++-- 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/ui/desktop/src/components/MarkdownContent.tsx b/ui/desktop/src/components/MarkdownContent.tsx index 5bd0708be083..37b207501301 100644 --- a/ui/desktop/src/components/MarkdownContent.tsx +++ b/ui/desktop/src/components/MarkdownContent.tsx @@ -29,7 +29,7 @@ const customOneDarkTheme = { import { Check, Copy } from './icons'; import { wrapHTMLInCodeBlock } from '../utils/htmlSecurity'; import { isProtocolSafe, getProtocol, BLOCKED_PROTOCOLS } from '../utils/urlSecurity'; -import { remarkLinkifyPaths, OPEN_FILE_PROTOCOL } from '../utils/linkifyPaths'; +import { remarkLinkifyPaths, OPEN_FILE_PROTOCOL, isTrustedGeneratedFileLink, decodeFileLinkHref } from '../utils/linkifyPaths'; import { ConfirmationModal } from './ui/ConfirmationModal'; import { defineMessages, useIntl } from '../i18n'; @@ -198,6 +198,19 @@ const customUrlTransform = (url: string): string => { return url; }; +function getAnchorText(children: React.ReactNode): string { + if (typeof children === 'string' || typeof children === 'number') { + return String(children); + } + if (Array.isArray(children)) { + return children.map(getAnchorText).join(''); + } + if (React.isValidElement<{ children?: React.ReactNode }>(children)) { + return getAnchorText(children.props.children); + } + return ''; +}; + const MarkdownContent = memo(function MarkdownContent({ content, className = '', @@ -274,13 +287,9 @@ const MarkdownContent = memo(function MarkdownContent({ a: (props) => { const href = props.href; if (href && href.startsWith(OPEN_FILE_PROTOCOL)) { - let filePath: string | undefined; - try { - filePath = decodeURIComponent(href.slice(OPEN_FILE_PROTOCOL.length)); - } catch { - filePath = undefined; - } - if (filePath) { + const filePath = decodeFileLinkHref(href); + const label = getAnchorText(props.children); + if (filePath && isTrustedGeneratedFileLink(href, label)) { return ( { describe('Unix absolute paths', () => { @@ -171,6 +171,15 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/Users/me/Project 2026'); }); + it('detects paths with @ and percent-encoded characters in filenames', () => { + expect(findPaths('File /tmp/foo@bar.txt')[0][1]).toBe('/tmp/foo@bar.txt'); + expect(findPaths('File /tmp/foo%20bar.txt')[0][1]).toBe('/tmp/foo%20bar.txt'); + }); + + it('does not linkify partial paths before unsupported characters', () => { + expect(findPaths('See /tmp/foo#bar.txt')).toHaveLength(0); + }); + it('detects paths with Unicode segment names', () => { const matches = findPaths('Saved /Users/me/デスクトップ/out.txt'); expect(matches).toHaveLength(1); @@ -269,3 +278,16 @@ describe('remarkLinkifyPaths', () => { expect(link.children[0].children[0]).toEqual({ type: 'text', value: '/tmp/out' }); }); }); + +describe('file link trust', () => { + it('trusts links whose label matches the decoded path', () => { + const path = '/Users/me/My Project/file.txt'; + const href = OPEN_FILE_PROTOCOL + encodeURI(path); + expect(isTrustedGeneratedFileLink(href, path)).toBe(true); + }); + + it('rejects authored links with mismatched labels', () => { + const href = OPEN_FILE_PROTOCOL + encodeURI('/Users/me/Secrets'); + expect(isTrustedGeneratedFileLink(href, 'release notes')).toBe(false); + }); +}); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 069c5d3ec196..2c3ac1be3f38 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -10,7 +10,7 @@ type PathMatch = [index: number, path: string]; type Separator = '/' | '\\'; function isPathChar(char: string): boolean { - if (/[a-zA-Z0-9._+-]/.test(char)) return true; + if (/[a-zA-Z0-9._+@%-]/.test(char)) return true; return /\p{L}|\p{N}/u.test(char); } @@ -169,6 +169,10 @@ function readSegment(text: string, start: number, separator: Separator): { end: return i > start ? { end: i } : null; } +function isPathTerminator(char: string): boolean { + return /[.,;:!?'"`]/.test(char); +} + function parsePathAt(text: string, index: number): PathMatch | null { let i = index; let separator: Separator = '/'; @@ -197,7 +201,7 @@ function parsePathAt(text: string, index: number): PathMatch | null { if (!segment) { if (segmentCount >= minSegments && i < text.length) { const next = text[i]; - if (next !== separator && next !== ' ' && !/[.,;:!?)'\]"]/.test(next)) { + if (next !== separator && next !== ' ' && !isPathTerminator(next)) { return null; } } @@ -209,6 +213,9 @@ function parsePathAt(text: string, index: number): PathMatch | null { i++; continue; } + if (i < text.length && text[i] !== ' ' && !isPathTerminator(text[i])) { + return null; + } break; } @@ -310,3 +317,17 @@ export const remarkLinkifyPaths: Plugin<[], Root> = function () { }; export { OPEN_FILE_PROTOCOL }; + +export function decodeFileLinkHref(href: string): string | undefined { + if (!href.startsWith(OPEN_FILE_PROTOCOL)) return undefined; + try { + return decodeURIComponent(href.slice(OPEN_FILE_PROTOCOL.length)); + } catch { + return undefined; + } +} + +export function isTrustedGeneratedFileLink(href: string, label: string): boolean { + const filePath = decodeFileLinkHref(href); + return filePath !== undefined && label === filePath; +} From 6f5003b5e198b130cf6d4ccff92a45596c23b042 Mon Sep 17 00:00:00 2001 From: Denis Date: Thu, 18 Jun 2026 21:12:55 +0300 Subject: [PATCH 14/53] fix(desktop): require longer spaced path segments Short lowercase path tokens no longer absorb following prose words like "for details" when the continuation is under four characters. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 6 ++++++ ui/desktop/src/utils/linkifyPaths.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index f4ab4467ab8d..1934b05f848a 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -93,6 +93,12 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/tmp/a'); }); + it('does not extend short basenames with following prose', () => { + const matches = findPaths('See /tmp/my for details'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/tmp/my'); + }); + it('includes spaced folder names before sentence punctuation', () => { const matches = findPaths('Saved to /home/user/my documents.'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 2c3ac1be3f38..fdeba445bd1a 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -61,6 +61,7 @@ function isPathLikeSpacedWord(word: string, prevToken: string): boolean { if (/^\d+$/.test(word)) return true; if (!/^[a-z0-9]+$/.test(word)) return true; return ( + word.length >= 4 && prevToken.length >= 2 && prevToken.length <= 3 && !prevToken.includes('.') From 98cd9d64c0885b9b1de99add5f8be3a99ad886fd Mon Sep 17 00:00:00 2001 From: Denis Date: Thu, 18 Jun 2026 21:29:00 +0300 Subject: [PATCH 15/53] fix(desktop): support word parentheticals and apostrophes in paths Accept alphabetic parenthesized filename suffixes like (final).pdf and allow embedded apostrophes in segment names so paths like it's.txt link correctly. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 12 ++++++++++++ ui/desktop/src/utils/linkifyPaths.ts | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 1934b05f848a..f39c8d4463ce 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -147,6 +147,12 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/Users/me/Downloads/report (1).pdf'); }); + it('detects paths with word parenthesized filename suffixes', () => { + const matches = findPaths('Saved /Users/me/Downloads/report (final).pdf'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/Users/me/Downloads/report (final).pdf'); + }); + it('preserves closing parens in parenthesized paths without extension', () => { const matches = findPaths('Saved /Users/me/Downloads/report (1)'); expect(matches).toHaveLength(1); @@ -165,6 +171,12 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/tmp/report,final.txt'); }); + it('detects paths with apostrophes in filenames', () => { + const matches = findPaths("Saved /tmp/it's.txt"); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe("/tmp/it's.txt"); + }); + it('does not extend paths across comma-separated prose', () => { const matches = findPaths('Created /tmp/report, then continued'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index fdeba445bd1a..8728190076df 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -87,7 +87,7 @@ function readParenthesizedSuffix(text: string, spaceIndex: number): number { if (i >= text.length) return spaceIndex; const content = text.slice(contentStart, i); - if (!/^\d+$/.test(content)) return spaceIndex; + if (!/^(\d+|[a-zA-Z]+)$/.test(content)) return spaceIndex; i++; while (i < text.length && isPathChar(text[i])) { @@ -133,7 +133,7 @@ function readSpacedContinuation( } function isFilenamePunctuation(char: string): boolean { - return char === ','; + return char === ',' || char === "'"; } function readSegment(text: string, start: number, separator: Separator): { end: number } | null { From 28c1b4d3da1db75c3ab31d81f8e0cd5c83ce2425 Mon Sep 17 00:00:00 2001 From: Denis Date: Thu, 18 Jun 2026 21:55:33 +0300 Subject: [PATCH 16/53] fix(desktop): support colons in timestamped filenames Treat embedded colons as filename punctuation when followed by path characters, so paths like /tmp/2026-06-18T18:30:00.log link fully. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 6 ++++++ ui/desktop/src/utils/linkifyPaths.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index f39c8d4463ce..1d995df7bcd9 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -177,6 +177,12 @@ describe('path linkification', () => { expect(matches[0][1]).toBe("/tmp/it's.txt"); }); + it('detects paths with colons in timestamped filenames', () => { + const matches = findPaths('Saved /tmp/2026-06-18T18:30:00.log'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/tmp/2026-06-18T18:30:00.log'); + }); + it('does not extend paths across comma-separated prose', () => { const matches = findPaths('Created /tmp/report, then continued'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 8728190076df..ae6f77ffd9c2 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -133,7 +133,7 @@ function readSpacedContinuation( } function isFilenamePunctuation(char: string): boolean { - return char === ',' || char === "'"; + return char === ',' || char === "'" || char === ':'; } function readSegment(text: string, start: number, separator: Separator): { end: number } | null { From 89e4145da0099f813602056651c6a27f5152c6a5 Mon Sep 17 00:00:00 2001 From: Denis Date: Thu, 18 Jun 2026 22:03:47 +0300 Subject: [PATCH 17/53] fix(desktop): support alphanumeric parenthetical filename suffixes Allow parenthesized suffixes like (v2) in filenames so paths such as report (v2).pdf link fully instead of stopping at the basename. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 6 ++++++ ui/desktop/src/utils/linkifyPaths.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 1d995df7bcd9..ff928442c0b1 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -153,6 +153,12 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/Users/me/Downloads/report (final).pdf'); }); + it('detects paths with alphanumeric parenthesized filename suffixes', () => { + const matches = findPaths('Saved /Users/me/Downloads/report (v2).pdf'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/Users/me/Downloads/report (v2).pdf'); + }); + it('preserves closing parens in parenthesized paths without extension', () => { const matches = findPaths('Saved /Users/me/Downloads/report (1)'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index ae6f77ffd9c2..dadaa49a97ee 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -87,7 +87,7 @@ function readParenthesizedSuffix(text: string, spaceIndex: number): number { if (i >= text.length) return spaceIndex; const content = text.slice(contentStart, i); - if (!/^(\d+|[a-zA-Z]+)$/.test(content)) return spaceIndex; + if (!/^[a-zA-Z0-9]+$/.test(content)) return spaceIndex; i++; while (i < text.length && isPathChar(text[i])) { From 8ffb75e3bd91bf1a8baf80c24fdfd90dd9847bc8 Mon Sep 17 00:00:00 2001 From: Denis Date: Thu, 18 Jun 2026 22:13:49 +0300 Subject: [PATCH 18/53] fix(desktop): stop prose after parenthesized path suffixes Skip spaced continuations after (N) or (v2) suffixes so paths like /tmp/report (1) successfully. do not absorb trailing prose words. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 6 ++++++ ui/desktop/src/utils/linkifyPaths.ts | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index ff928442c0b1..f28005d15c15 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -171,6 +171,12 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/Users/me/Downloads/report (1)'); }); + it('does not append prose after parenthesized filename suffixes', () => { + const matches = findPaths('Saved /tmp/report (1) successfully.'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/tmp/report (1)'); + }); + it('detects paths with commas in filenames', () => { const matches = findPaths('Saved /tmp/report,final.txt'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index dadaa49a97ee..553c9b482231 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -57,7 +57,12 @@ function getLastToken(text: string, segmentStart: number, beforeIndex: number): return lastSpace === -1 ? segment : segment.slice(lastSpace + 1); } +function isParenthesizedSuffix(token: string): boolean { + return /^\([^)]+\)$/.test(token); +} + function isPathLikeSpacedWord(word: string, prevToken: string): boolean { + if (isParenthesizedSuffix(prevToken)) return false; if (/^\d+$/.test(word)) return true; if (!/^[a-z0-9]+$/.test(word)) return true; return ( From 79526c8ee8e72bccc0f8c5ea6cfbdc62592de1e9 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 03:02:20 +0300 Subject: [PATCH 19/53] fix(desktop): link paths wrapped in parentheses or brackets Treat closing ) and ] as path terminators so prose wrappers like (/tmp/out) and (C:\Users\dev\file.txt) still produce file links. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 6 ++++++ ui/desktop/src/utils/linkifyPaths.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index f28005d15c15..26a85bb61809 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -69,6 +69,12 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/var/log/app.log'); }); + it('detects paths wrapped in parentheses or brackets', () => { + expect(findPaths('See (/tmp/out)')[0][1]).toBe('/tmp/out'); + expect(findPaths('See (C:\\Users\\dev\\file.txt)')[0][1]).toBe('C:\\Users\\dev\\file.txt'); + expect(findPaths('See [/tmp/out]')[0][1]).toBe('/tmp/out'); + }); + it('detects paths with dots and underscores', () => { const matches = findPaths('Read /home/user/.env.local'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 553c9b482231..265d7de58926 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -176,7 +176,7 @@ function readSegment(text: string, start: number, separator: Separator): { end: } function isPathTerminator(char: string): boolean { - return /[.,;:!?'"`]/.test(char); + return /[.,;:!?'"`[\])]/.test(char); } function parsePathAt(text: string, index: number): PathMatch | null { From 0c1eec5c6dec1509b32cf703f256d78a473b81af Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 03:11:25 +0300 Subject: [PATCH 20/53] fix(desktop): allow brackets and question marks in path filenames Treat [ ] and ? as filename punctuation when followed by path characters, and stop using them as global terminators so names like report[1].pdf and what?now.txt link fully. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 5 +++++ ui/desktop/src/utils/linkifyPaths.ts | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 26a85bb61809..e43bca425f15 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -75,6 +75,11 @@ describe('path linkification', () => { expect(findPaths('See [/tmp/out]')[0][1]).toBe('/tmp/out'); }); + it('detects paths with brackets and question marks in filenames', () => { + expect(findPaths('Saved /tmp/report[1].pdf')[0][1]).toBe('/tmp/report[1].pdf'); + expect(findPaths('Saved /tmp/what?now.txt')[0][1]).toBe('/tmp/what?now.txt'); + }); + it('detects paths with dots and underscores', () => { const matches = findPaths('Read /home/user/.env.local'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 265d7de58926..761cbcbbb7a2 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -138,7 +138,7 @@ function readSpacedContinuation( } function isFilenamePunctuation(char: string): boolean { - return char === ',' || char === "'" || char === ':'; + return char === ',' || char === "'" || char === ':' || char === '[' || char === ']' || char === '?'; } function readSegment(text: string, start: number, separator: Separator): { end: number } | null { @@ -176,7 +176,7 @@ function readSegment(text: string, start: number, separator: Separator): { end: } function isPathTerminator(char: string): boolean { - return /[.,;:!?'"`[\])]/.test(char); + return /[.,;:!'"`)\]]/.test(char); } function parsePathAt(text: string, index: number): PathMatch | null { From a84c9f56fe8775f9403eb591145ac7b3e00e1829 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 03:19:56 +0300 Subject: [PATCH 21/53] fix(desktop): reject numeric counts after path segments Only treat spaced digit words as path continuations for multi-digit years or capitalized segment names, so counts like "2 files" after /tmp/out are not absorbed into the link. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 6 ++++++ ui/desktop/src/utils/linkifyPaths.ts | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index e43bca425f15..4c08779cf801 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -218,6 +218,12 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/Users/me/Project 2026'); }); + it('does not extend paths with trailing numeric counts', () => { + const matches = findPaths('Created /tmp/out 2 files'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/tmp/out'); + }); + it('detects paths with @ and percent-encoded characters in filenames', () => { expect(findPaths('File /tmp/foo@bar.txt')[0][1]).toBe('/tmp/foo@bar.txt'); expect(findPaths('File /tmp/foo%20bar.txt')[0][1]).toBe('/tmp/foo%20bar.txt'); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 761cbcbbb7a2..0fe08aa94550 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -63,7 +63,9 @@ function isParenthesizedSuffix(token: string): boolean { function isPathLikeSpacedWord(word: string, prevToken: string): boolean { if (isParenthesizedSuffix(prevToken)) return false; - if (/^\d+$/.test(word)) return true; + if (/^\d+$/.test(word)) { + return word.length >= 4 || /^[A-Z]/.test(prevToken); + } if (!/^[a-z0-9]+$/.test(word)) return true; return ( word.length >= 4 && From b940ba790c93e1d1a90537846bde55615a1a0613 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 03:31:45 +0300 Subject: [PATCH 22/53] fix(desktop): reject capitalized prose after path segments Only treat capitalized spaced words as path continuations when the previous segment also starts with a capital letter, so replies like "Please review" after /tmp/out are not absorbed into the link. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 6 ++++++ ui/desktop/src/utils/linkifyPaths.ts | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 4c08779cf801..4e16b8df170c 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -224,6 +224,12 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/tmp/out'); }); + it('does not extend paths with capitalized prose', () => { + const matches = findPaths('See /tmp/out Please review.'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/tmp/out'); + }); + it('detects paths with @ and percent-encoded characters in filenames', () => { expect(findPaths('File /tmp/foo@bar.txt')[0][1]).toBe('/tmp/foo@bar.txt'); expect(findPaths('File /tmp/foo%20bar.txt')[0][1]).toBe('/tmp/foo%20bar.txt'); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 0fe08aa94550..ee8448b469cf 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -66,7 +66,9 @@ function isPathLikeSpacedWord(word: string, prevToken: string): boolean { if (/^\d+$/.test(word)) { return word.length >= 4 || /^[A-Z]/.test(prevToken); } - if (!/^[a-z0-9]+$/.test(word)) return true; + if (/^[A-Z]/.test(word)) { + return /^[A-Z]/.test(prevToken); + } return ( word.length >= 4 && prevToken.length >= 2 && From ca461aa7d65ac16804f8d98907700f6a3052648b Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 03:42:52 +0300 Subject: [PATCH 23/53] fix(desktop): include balanced bracket suffixes in path segments Read bracketed filename suffixes like [1] atomically so paths such as /tmp/report[1] link fully instead of stopping before the closing bracket. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 4 ++++ ui/desktop/src/utils/linkifyPaths.ts | 21 ++++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 4e16b8df170c..9ea4848ad212 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -80,6 +80,10 @@ describe('path linkification', () => { expect(findPaths('Saved /tmp/what?now.txt')[0][1]).toBe('/tmp/what?now.txt'); }); + it('detects paths with bracketed suffixes without extension', () => { + expect(findPaths('Saved /tmp/report[1]')[0][1]).toBe('/tmp/report[1]'); + }); + it('detects paths with dots and underscores', () => { const matches = findPaths('Read /home/user/.env.local'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index ee8448b469cf..13d45995026f 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -142,7 +142,21 @@ function readSpacedContinuation( } function isFilenamePunctuation(char: string): boolean { - return char === ',' || char === "'" || char === ':' || char === '[' || char === ']' || char === '?'; + return char === ',' || char === "'" || char === ':' || char === '?'; +} + +function readBracketedSuffix(text: string, bracketIndex: number): number { + if (text[bracketIndex] !== '[') return bracketIndex; + + let j = bracketIndex + 1; + if (j >= text.length) return bracketIndex; + + while (j < text.length && isPathChar(text[j])) { + j++; + } + if (j >= text.length || text[j] !== ']') return bracketIndex; + + return j + 1; } function readSegment(text: string, start: number, separator: Separator): { end: number } | null { @@ -154,6 +168,11 @@ function readSegment(text: string, start: number, separator: Separator): { end: i++; continue; } + const bracketEnd = readBracketedSuffix(text, i); + if (bracketEnd > i) { + i = bracketEnd; + continue; + } if ( isFilenamePunctuation(text[i]) && i + 1 < text.length && From 2c56380a2705ca976b381fbc2ca0f8272af55463 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 03:52:56 +0300 Subject: [PATCH 24/53] fix(desktop): include bracket suffixes in spaced path segments Extend readSpacedContinuation through balanced [N] suffixes and any following extension so paths like /tmp/My report[1].pdf link fully. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 4 ++++ ui/desktop/src/utils/linkifyPaths.ts | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 9ea4848ad212..f4622d5f4f85 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -84,6 +84,10 @@ describe('path linkification', () => { expect(findPaths('Saved /tmp/report[1]')[0][1]).toBe('/tmp/report[1]'); }); + it('detects paths with bracketed suffixes after spaced filename segments', () => { + expect(findPaths('Saved /tmp/My report[1].pdf')[0][1]).toBe('/tmp/My report[1].pdf'); + }); + it('detects paths with dots and underscores', () => { const matches = findPaths('Read /home/user/.env.local'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 13d45995026f..8f8e2ee1ed2f 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -117,6 +117,13 @@ function readSpacedContinuation( while (j < text.length && isPathChar(text[j])) { j++; } + const bracketEnd = readBracketedSuffix(text, j); + if (bracketEnd > j) { + j = bracketEnd; + while (j < text.length && isPathChar(text[j])) { + j++; + } + } while (j > spaceIndex + 1 && /[.,;:!?)'\]"]/.test(text[j - 1] ?? '')) { j--; } From 4d8de80ea9e86ec8e86a0daae6e989cc8dc66546 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 04:02:59 +0300 Subject: [PATCH 25/53] fix(desktop): link long lowercase spaced filename segments Accept spaced continuations with file extensions after longer lowercase prefix tokens so paths like /tmp/project notes.txt link fully. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 4 ++++ ui/desktop/src/utils/linkifyPaths.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index f4622d5f4f85..1df1f8b16cf6 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -88,6 +88,10 @@ describe('path linkification', () => { expect(findPaths('Saved /tmp/My report[1].pdf')[0][1]).toBe('/tmp/My report[1].pdf'); }); + it('detects paths with long lowercase spaced filename segments', () => { + expect(findPaths('Saved /tmp/project notes.txt')[0][1]).toBe('/tmp/project notes.txt'); + }); + it('detects paths with dots and underscores', () => { const matches = findPaths('Read /home/user/.env.local'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 8f8e2ee1ed2f..f421b1f8e9f2 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -69,6 +69,9 @@ function isPathLikeSpacedWord(word: string, prevToken: string): boolean { if (/^[A-Z]/.test(word)) { return /^[A-Z]/.test(prevToken); } + if (/\.[A-Za-z0-9]+$/.test(word) && /^[a-z]/.test(prevToken)) { + return true; + } return ( word.length >= 4 && prevToken.length >= 2 && From 93fafd02078857da96a5ece6f36e3050eff70266 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 04:13:02 +0300 Subject: [PATCH 26/53] fix(desktop): strip line suffixes and link multiword filenames Strip trailing :line[:column] from compiler locations and look ahead for extension words when absorbing lowercase spaced filename segments. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 10 ++++++ ui/desktop/src/utils/linkifyPaths.ts | 42 +++++++++++++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 1df1f8b16cf6..be99e63b6b0a 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -92,6 +92,16 @@ describe('path linkification', () => { expect(findPaths('Saved /tmp/project notes.txt')[0][1]).toBe('/tmp/project notes.txt'); }); + it('detects paths with multiple lowercase spaced filename words', () => { + expect(findPaths('Saved /tmp/project notes draft.txt')[0][1]).toBe( + '/tmp/project notes draft.txt' + ); + }); + + it('strips trailing line and column suffixes from paths', () => { + expect(findPaths('error at /workspace/src/lib.rs:42:7')[0][1]).toBe('/workspace/src/lib.rs'); + }); + it('detects paths with dots and underscores', () => { const matches = findPaths('Read /home/user/.env.local'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index f421b1f8e9f2..88a73c142bb4 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -18,6 +18,10 @@ function stripTrailingPunctuation(path: string): string { return path.replace(TRAILING_PUNCTUATION_RE, ''); } +function stripLineColumnSuffix(path: string): string { + return path.replace(/:\d+(?::\d+)?$/, ''); +} + function isUrlPathAt(text: string, index: number): boolean { let i = index - 1; if (i < 0) return false; @@ -80,6 +84,38 @@ function isPathLikeSpacedWord(word: string, prevToken: string): boolean { ); } +function readExtensionWordAhead(text: string, fromIndex: number): boolean { + let i = fromIndex; + while (i < text.length) { + if (text[i] !== ' ') return false; + i++; + let j = i; + while (j < text.length && isPathChar(text[j])) { + j++; + } + if (j === i) return false; + const word = text.slice(i, j); + if (/\.[A-Za-z0-9]+$/.test(word) && /^[a-z]/.test(word)) return true; + if (!/^[a-z][a-z0-9]*$/.test(word)) return false; + i = j; + } + return false; +} + +function isSpacedFilenameContinuation( + word: string, + prevToken: string, + text: string, + endIndex: number +): boolean { + if (isPathLikeSpacedWord(word, prevToken)) return true; + return ( + /^[a-z][a-z0-9]*$/.test(word) && + /^[a-z]/.test(prevToken) && + readExtensionWordAhead(text, endIndex) + ); +} + function isLinkLikeParent(parent: Parent | undefined): boolean { return parent?.type === 'link' || parent?.type === 'linkReference'; } @@ -139,13 +175,13 @@ function readSpacedContinuation( if (after === separator) return j; if (after === undefined || /[.,;:!?)'\]"]/.test(after)) { - return isPathLikeSpacedWord(word, prevToken) ? j : spaceIndex; + return isSpacedFilenameContinuation(word, prevToken, text, j) ? j : spaceIndex; } if (after === ' ') { const rest = text.slice(j).trimStart(); if (rest.startsWith(separator)) return spaceIndex; - return isPathLikeSpacedWord(word, prevToken) ? j : spaceIndex; + return isSpacedFilenameContinuation(word, prevToken, text, j) ? j : spaceIndex; } return spaceIndex; @@ -260,7 +296,7 @@ function parsePathAt(text: string, index: number): PathMatch | null { if (segmentCount < minSegments) return null; - const path = stripTrailingPunctuation(text.slice(index, i)); + const path = stripLineColumnSuffix(stripTrailingPunctuation(text.slice(index, i))); if (path.length === 0) return null; return [index, path]; From ebf26e81f09fb019a1bfbe48072eaaab3d237019 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 04:23:41 +0300 Subject: [PATCH 27/53] fix(desktop): refine line suffix stripping and tab terminators Only strip :line[:column] after source file extensions and treat any whitespace as a path boundary so snapshot:1 and tab-separated paths link correctly. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 10 ++++++++++ ui/desktop/src/utils/linkifyPaths.ts | 16 +++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index be99e63b6b0a..e6e60a32b4df 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -100,6 +100,16 @@ describe('path linkification', () => { it('strips trailing line and column suffixes from paths', () => { expect(findPaths('error at /workspace/src/lib.rs:42:7')[0][1]).toBe('/workspace/src/lib.rs'); + expect(findPaths('error at /workspace/src/lib.rs:42')[0][1]).toBe('/workspace/src/lib.rs'); + }); + + it('preserves colon-number suffixes in filenames', () => { + expect(findPaths('Saved /tmp/snapshot:1')[0][1]).toBe('/tmp/snapshot:1'); + expect(findPaths('Saved /tmp/2026-06-18T18:30:00')[0][1]).toBe('/tmp/2026-06-18T18:30:00'); + }); + + it('detects paths followed by tab-separated text', () => { + expect(findPaths('See /tmp/out\tOK')[0][1]).toBe('/tmp/out'); }); it('detects paths with dots and underscores', () => { diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 88a73c142bb4..d5ae8854d99a 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -19,7 +19,13 @@ function stripTrailingPunctuation(path: string): string { } function stripLineColumnSuffix(path: string): string { - return path.replace(/:\d+(?::\d+)?$/, ''); + const match = path.match( + /\.(?:rs|c|cpp|cxx|cc|h|hpp|go|java|js|jsx|ts|tsx|py|rb|swift|kt|scala|cs|php|m|mm|vue|svelte)(:\d+(?::\d+)?)$/i + ); + if (match) { + return path.slice(0, -match[1].length); + } + return path; } function isUrlPathAt(text: string, index: number): boolean { @@ -248,6 +254,10 @@ function isPathTerminator(char: string): boolean { return /[.,;:!'"`)\]]/.test(char); } +function isPathBoundary(char: string): boolean { + return /\s/.test(char) || isPathTerminator(char); +} + function parsePathAt(text: string, index: number): PathMatch | null { let i = index; let separator: Separator = '/'; @@ -276,7 +286,7 @@ function parsePathAt(text: string, index: number): PathMatch | null { if (!segment) { if (segmentCount >= minSegments && i < text.length) { const next = text[i]; - if (next !== separator && next !== ' ' && !isPathTerminator(next)) { + if (next !== separator && !isPathBoundary(next)) { return null; } } @@ -288,7 +298,7 @@ function parsePathAt(text: string, index: number): PathMatch | null { i++; continue; } - if (i < text.length && text[i] !== ' ' && !isPathTerminator(text[i])) { + if (i < text.length && !isPathBoundary(text[i])) { return null; } break; From 194d0515fdb54ab6e4f6ae1f44bc94013312f8bb Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 04:42:58 +0300 Subject: [PATCH 28/53] fix(desktop): preserve bracket suffixes and reject prose parentheticals Stop trimming closing brackets from spaced [draft] suffixes and only accept word parentheticals when followed by filename text or short tags. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 8 ++++++++ ui/desktop/src/utils/linkifyPaths.ts | 17 +++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index e6e60a32b4df..978d5b58d1a4 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -112,6 +112,14 @@ describe('path linkification', () => { expect(findPaths('See /tmp/out\tOK')[0][1]).toBe('/tmp/out'); }); + it('detects paths with spaced bracket suffixes', () => { + expect(findPaths('Saved /tmp/log [draft]')[0][1]).toBe('/tmp/log [draft]'); + }); + + it('does not absorb explanatory parentheticals after paths', () => { + expect(findPaths('Created /tmp/out (temporary) for debugging')[0][1]).toBe('/tmp/out'); + }); + it('detects paths with dots and underscores', () => { const matches = findPaths('Read /home/user/.env.local'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index d5ae8854d99a..23b3f5a81350 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -114,6 +114,9 @@ function isSpacedFilenameContinuation( text: string, endIndex: number ): boolean { + if (/^\[[^\]]+\]$/.test(word)) { + return prevToken.length >= 2; + } if (isPathLikeSpacedWord(word, prevToken)) return true; return ( /^[a-z][a-z0-9]*$/.test(word) && @@ -144,10 +147,20 @@ function readParenthesizedSuffix(text: string, spaceIndex: number): number { if (!/^[a-zA-Z0-9]+$/.test(content)) return spaceIndex; i++; + const afterParen = i; while (i < text.length && isPathChar(text[i])) { i++; } - return i; + if (/^\d+$/.test(content)) { + return i; + } + if (i > afterParen) { + return i; + } + if (/^[a-zA-Z0-9]{1,4}$/.test(content)) { + return afterParen; + } + return spaceIndex; } function readSpacedContinuation( @@ -169,7 +182,7 @@ function readSpacedContinuation( j++; } } - while (j > spaceIndex + 1 && /[.,;:!?)'\]"]/.test(text[j - 1] ?? '')) { + while (j > spaceIndex + 1 && /[.,;:!?)'"]/.test(text[j - 1] ?? '')) { j--; } if (j === spaceIndex + 1) return spaceIndex; From c05a4b398226fb09f003b12477a3b41f3b50c218 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 04:52:54 +0300 Subject: [PATCH 29/53] fix(desktop): broaden line suffix stripping and reject bracket tags Strip :line[:column] after any dotted extension including toml and md, and only accept spaced bracket continuations with lowercase or numeric inner text so status tags like [OK] stay outside the link. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 8 ++++++++ ui/desktop/src/utils/linkifyPaths.ts | 10 ++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 978d5b58d1a4..7919b75082a3 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -101,6 +101,10 @@ describe('path linkification', () => { it('strips trailing line and column suffixes from paths', () => { expect(findPaths('error at /workspace/src/lib.rs:42:7')[0][1]).toBe('/workspace/src/lib.rs'); expect(findPaths('error at /workspace/src/lib.rs:42')[0][1]).toBe('/workspace/src/lib.rs'); + expect(findPaths('error at /workspace/goose/Cargo.toml:12:5')[0][1]).toBe( + '/workspace/goose/Cargo.toml' + ); + expect(findPaths('See /project/README.md:3 for details')[0][1]).toBe('/project/README.md'); }); it('preserves colon-number suffixes in filenames', () => { @@ -116,6 +120,10 @@ describe('path linkification', () => { expect(findPaths('Saved /tmp/log [draft]')[0][1]).toBe('/tmp/log [draft]'); }); + it('does not absorb bracket status annotations after paths', () => { + expect(findPaths('Created /tmp/out [OK] for upload')[0][1]).toBe('/tmp/out'); + }); + it('does not absorb explanatory parentheticals after paths', () => { expect(findPaths('Created /tmp/out (temporary) for debugging')[0][1]).toBe('/tmp/out'); }); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 23b3f5a81350..5d9f83852e07 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -19,9 +19,7 @@ function stripTrailingPunctuation(path: string): string { } function stripLineColumnSuffix(path: string): string { - const match = path.match( - /\.(?:rs|c|cpp|cxx|cc|h|hpp|go|java|js|jsx|ts|tsx|py|rb|swift|kt|scala|cs|php|m|mm|vue|svelte)(:\d+(?::\d+)?)$/i - ); + const match = path.match(/\.[A-Za-z0-9]+(:\d+(?::\d+)?)$/); if (match) { return path.slice(0, -match[1].length); } @@ -115,7 +113,11 @@ function isSpacedFilenameContinuation( endIndex: number ): boolean { if (/^\[[^\]]+\]$/.test(word)) { - return prevToken.length >= 2; + const inner = word.slice(1, -1); + return ( + prevToken.length >= 2 && + (/^[a-z][a-z0-9]*$/.test(inner) || /^\d+$/.test(inner)) + ); } if (isPathLikeSpacedWord(word, prevToken)) return true; return ( From e3d68394f8cd33ae39bbea5db305f2493a0e03b8 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 05:02:47 +0300 Subject: [PATCH 30/53] fix(desktop): reject short counts after capitalized path segments Only accept spaced digit continuations for multi-digit values like years, not single-digit counts after capitalized basenames such as Out. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 4 ++++ ui/desktop/src/utils/linkifyPaths.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 7919b75082a3..f43cf254261b 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -272,6 +272,10 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/tmp/out'); }); + it('does not extend capitalized paths with short numeric counts', () => { + expect(findPaths('Created /tmp/Out 2 files')[0][1]).toBe('/tmp/Out'); + }); + it('does not extend paths with capitalized prose', () => { const matches = findPaths('See /tmp/out Please review.'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 5d9f83852e07..9445faeefecd 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -72,7 +72,7 @@ function isParenthesizedSuffix(token: string): boolean { function isPathLikeSpacedWord(word: string, prevToken: string): boolean { if (isParenthesizedSuffix(prevToken)) return false; if (/^\d+$/.test(word)) { - return word.length >= 4 || /^[A-Z]/.test(prevToken); + return word.length >= 4; } if (/^[A-Z]/.test(word)) { return /^[A-Z]/.test(prevToken); From c78b04f7fe8eb71a8456925c63ce6d5cdde57de5 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 05:22:42 +0300 Subject: [PATCH 31/53] fix(desktop): strip punctuation in extension lookahead words Ignore trailing sentence punctuation when scanning ahead for filename extensions so multi-word paths like project notes draft.txt. link fully. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 3 +++ ui/desktop/src/utils/linkifyPaths.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index f43cf254261b..afb2c153147a 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -96,6 +96,9 @@ describe('path linkification', () => { expect(findPaths('Saved /tmp/project notes draft.txt')[0][1]).toBe( '/tmp/project notes draft.txt' ); + expect(findPaths('Saved /tmp/project notes draft.txt.')[0][1]).toBe( + '/tmp/project notes draft.txt' + ); }); it('strips trailing line and column suffixes from paths', () => { diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 9445faeefecd..edbbb444f5a9 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -98,7 +98,7 @@ function readExtensionWordAhead(text: string, fromIndex: number): boolean { j++; } if (j === i) return false; - const word = text.slice(i, j); + const word = text.slice(i, j).replace(TRAILING_PUNCTUATION_RE, ''); if (/\.[A-Za-z0-9]+$/.test(word) && /^[a-z]/.test(word)) return true; if (!/^[a-z][a-z0-9]*$/.test(word)) return false; i = j; From 3a727e52219cae09798c6c5444797e5122e4b457 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 05:32:51 +0300 Subject: [PATCH 32/53] fix(desktop): link top-level absolute directories Allow single-segment Unix paths like /tmp and /workspace while still rejecting bare root /. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 7 ++++++- ui/desktop/src/utils/linkifyPaths.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index afb2c153147a..11a63952f826 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -10,11 +10,16 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/home/user/project/src/main.rs'); }); - it('does not match single-segment paths', () => { + it('does not match bare root path', () => { const matches = findPaths('Go to / for root'); expect(matches).toHaveLength(0); }); + it('detects top-level absolute directories', () => { + expect(findPaths('Saved to /tmp')[0][1]).toBe('/tmp'); + expect(findPaths('Saved to /workspace')[0][1]).toBe('/workspace'); + }); + it('detects multiple paths in one line', () => { const matches = findPaths('Compare /etc/hosts and /etc/resolv.conf'); expect(matches).toHaveLength(2); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index edbbb444f5a9..603cca46886c 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -280,7 +280,7 @@ function parsePathAt(text: string, index: number): PathMatch | null { if (text[i] === '/') { i++; - minSegments = 2; + minSegments = 1; } else if (text[i] === '~' && text[i + 1] === '/') { i += 2; minSegments = 1; From 64c45b60ab855e4a647ef31c0923d70b7b80fda1 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 05:42:47 +0300 Subject: [PATCH 33/53] fix(desktop): link paths in questions and Windows drive dirs Treat trailing ? as a path terminator and allow single-segment Windows paths like C:\Users while preserving embedded ? in filenames. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 9 +++++++++ ui/desktop/src/utils/linkifyPaths.ts | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 11a63952f826..db67f6465099 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -20,6 +20,15 @@ describe('path linkification', () => { expect(findPaths('Saved to /workspace')[0][1]).toBe('/workspace'); }); + it('detects paths in questions with trailing question marks', () => { + expect(findPaths('Can you check /tmp/out?')[0][1]).toBe('/tmp/out'); + }); + + it('detects single-segment Windows absolute directories', () => { + expect(findPaths('See C:\\Users for details')[0][1]).toBe('C:\\Users'); + expect(findPaths('See C:\\Windows for details')[0][1]).toBe('C:\\Windows'); + }); + it('detects multiple paths in one line', () => { const matches = findPaths('Compare /etc/hosts and /etc/resolv.conf'); expect(matches).toHaveLength(2); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 603cca46886c..aa54055acce5 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -266,7 +266,7 @@ function readSegment(text: string, start: number, separator: Separator): { end: } function isPathTerminator(char: string): boolean { - return /[.,;:!'"`)\]]/.test(char); + return /[.,;:!?'"`)\]]/.test(char); } function isPathBoundary(char: string): boolean { @@ -290,7 +290,7 @@ function parsePathAt(text: string, index: number): PathMatch | null { separator = text[i] as Separator; i++; } - minSegments = 2; + minSegments = 1; } else { return null; } From 7fbd2400f164665d866202b31e497b17effaf719 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 06:02:52 +0300 Subject: [PATCH 34/53] fix(desktop): require drive separator for Windows paths Reject drive-relative matches like A:retry in prose and only link Windows paths when a slash or backslash follows the drive letter. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 5 +++++ ui/desktop/src/utils/linkifyPaths.ts | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index db67f6465099..4e9eb2cd8950 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -74,6 +74,11 @@ describe('path linkification', () => { expect(matches).toHaveLength(1); expect(matches[0][1]).toBe('C:\\Users\\dev\\My Project\\index.ts'); }); + + it('does not treat prose labels as Windows paths', () => { + expect(findPaths('Option A:retry')).toHaveLength(0); + expect(findPaths('Status C:passed')).toHaveLength(0); + }); }); describe('Edge cases', () => { diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index aa54055acce5..0290bc956111 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -286,10 +286,11 @@ function parsePathAt(text: string, index: number): PathMatch | null { minSegments = 1; } else if (/[A-Za-z]/.test(text[i] ?? '') && text[i + 1] === ':') { i += 2; - if (text[i] === '/' || text[i] === '\\') { - separator = text[i] as Separator; - i++; + if (text[i] !== '/' && text[i] !== '\\') { + return null; } + separator = text[i] as Separator; + i++; minSegments = 1; } else { return null; From 10a76ba2bba7c329360af118bc5e2c1b992ecbfe Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 06:12:55 +0300 Subject: [PATCH 35/53] fix(desktop): accept assignment prefixes and newline terminators Handle non-space whitespace after spaced filename continuations and allow paths after = in artifact=/tmp/out.log style tool output. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 11 +++++++++++ ui/desktop/src/utils/linkifyPaths.ts | 10 ++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 4e9eb2cd8950..849a32f2c569 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -186,6 +186,17 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/home/user/my documents'); }); + it('includes spaced folder names before newline terminators', () => { + expect(findPaths('Saved /home/user/my documents\nDone')[0][1]).toBe( + '/home/user/my documents' + ); + }); + + it('detects paths after assignment separators', () => { + expect(findPaths('artifact=/tmp/out.log')[0][1]).toBe('/tmp/out.log'); + expect(findPaths('--output=/tmp/out')[0][1]).toBe('/tmp/out'); + }); + it('does not match URLs', () => { const matches = findPaths('Visit https://example.com/page for info'); expect(matches).toHaveLength(0); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 0290bc956111..d1b361c29cfc 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -50,7 +50,7 @@ function isCandidatePathStart(text: string, index: number, afterBlockComment: bo if (index === 0) return true; if (isUrlPathAt(text, index)) return false; const prev = text[index - 1]; - if (/[\s('"`[(,;]/.test(prev)) return true; + if (/[\s('"`[(,;=]/.test(prev)) return true; return afterBlockComment; } @@ -199,9 +199,11 @@ function readSpacedContinuation( return isSpacedFilenameContinuation(word, prevToken, text, j) ? j : spaceIndex; } - if (after === ' ') { - const rest = text.slice(j).trimStart(); - if (rest.startsWith(separator)) return spaceIndex; + if (after !== undefined && /\s/.test(after)) { + if (after === ' ') { + const rest = text.slice(j).trimStart(); + if (rest.startsWith(separator)) return spaceIndex; + } return isSpacedFilenameContinuation(word, prevToken, text, j) ? j : spaceIndex; } From 65d41d25969a68182573a70054da9481381aecd8 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 06:32:55 +0300 Subject: [PATCH 36/53] fix(desktop): tighten capitalized and parenthetical continuations Reject capitalized prose after long capitalized basenames and short alpha parentheticals like (temp) unless they include digits or a filename extension follows. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 5 +++++ ui/desktop/src/utils/linkifyPaths.ts | 12 ++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 849a32f2c569..8c7ee3f783be 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -148,6 +148,7 @@ describe('path linkification', () => { it('does not absorb explanatory parentheticals after paths', () => { expect(findPaths('Created /tmp/out (temporary) for debugging')[0][1]).toBe('/tmp/out'); + expect(findPaths('Created /tmp/out (temp) for debugging')[0][1]).toBe('/tmp/out'); }); it('detects paths with dots and underscores', () => { @@ -315,6 +316,10 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/tmp/out'); }); + it('does not extend capitalized basenames with following prose', () => { + expect(findPaths('See /tmp/Result Please review.')[0][1]).toBe('/tmp/Result'); + }); + it('detects paths with @ and percent-encoded characters in filenames', () => { expect(findPaths('File /tmp/foo@bar.txt')[0][1]).toBe('/tmp/foo@bar.txt'); expect(findPaths('File /tmp/foo%20bar.txt')[0][1]).toBe('/tmp/foo%20bar.txt'); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index d1b361c29cfc..9d601f5f9a5c 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -75,7 +75,15 @@ function isPathLikeSpacedWord(word: string, prevToken: string): boolean { return word.length >= 4; } if (/^[A-Z]/.test(word)) { - return /^[A-Z]/.test(prevToken); + if (/\.[A-Za-z0-9]+$/.test(word)) { + return /^[A-Z]/.test(prevToken); + } + return ( + /^[A-Z]/.test(prevToken) && + word.length >= 4 && + prevToken.length >= 2 && + prevToken.length <= 3 + ); } if (/\.[A-Za-z0-9]+$/.test(word) && /^[a-z]/.test(prevToken)) { return true; @@ -159,7 +167,7 @@ function readParenthesizedSuffix(text: string, spaceIndex: number): number { if (i > afterParen) { return i; } - if (/^[a-zA-Z0-9]{1,4}$/.test(content)) { + if (/^[a-zA-Z0-9]{1,4}$/.test(content) && /\d/.test(content)) { return afterParen; } return spaceIndex; From 8d5f57bafe1e8c8d881a162c243d9dfe7f97c64f Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 06:42:57 +0300 Subject: [PATCH 37/53] fix(desktop): reject prose before slashes and after short basenames Require path evidence before crossing a separator and limit the short prevToken spaced-word heuristic to two-character prefixes like my. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 10 ++++++++++ ui/desktop/src/utils/linkifyPaths.ts | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 8c7ee3f783be..87840fcb3a21 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -181,6 +181,16 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/tmp/my'); }); + it('does not extend short basenames with generic nouns', () => { + expect(findPaths('Check /tmp/log file for details')[0][1]).toBe('/tmp/log'); + }); + + it('does not extend paths through connective prose before another slash', () => { + const matches = findPaths('Review /tmp/output and/or /tmp/logs'); + expect(matches[0][1]).toBe('/tmp/output'); + expect(matches[1][1]).toBe('/tmp/logs'); + }); + it('includes spaced folder names before sentence punctuation', () => { const matches = findPaths('Saved to /home/user/my documents.'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 9d601f5f9a5c..0cbcfcfd2166 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -91,7 +91,7 @@ function isPathLikeSpacedWord(word: string, prevToken: string): boolean { return ( word.length >= 4 && prevToken.length >= 2 && - prevToken.length <= 3 && + prevToken.length <= 2 && !prevToken.includes('.') ); } @@ -201,7 +201,9 @@ function readSpacedContinuation( const prevToken = getLastToken(text, segmentStart, spaceIndex); const after = text[j]; - if (after === separator) return j; + if (after === separator) { + return isSpacedFilenameContinuation(word, prevToken, text, j) ? j : spaceIndex; + } if (after === undefined || /[.,;:!?)'\]"]/.test(after)) { return isSpacedFilenameContinuation(word, prevToken, text, j) ? j : spaceIndex; From 60e6e8e9dee86aedf15295e1d6cddc1f446cf3be Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 07:44:44 +0300 Subject: [PATCH 38/53] fix(desktop): preserve spaced directory names before separators Allow spaced continuations when the next character is a path separator except for connective words like and/or in prose. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 7 +++++++ ui/desktop/src/utils/linkifyPaths.ts | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 87840fcb3a21..3f7eb033cb93 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -191,6 +191,13 @@ describe('path linkification', () => { expect(matches[1][1]).toBe('/tmp/logs'); }); + it('detects paths with spaced directory names before separators', () => { + expect(findPaths('/Users/me/Library/Application Support/Goose')[0][1]).toBe( + '/Users/me/Library/Application Support/Goose' + ); + expect(findPaths('See C:\\Program Files\\Goose')[0][1]).toBe('C:\\Program Files\\Goose'); + }); + it('includes spaced folder names before sentence punctuation', () => { const matches = findPaths('Saved to /home/user/my documents.'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 0cbcfcfd2166..12eb18a0069c 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -173,6 +173,10 @@ function readParenthesizedSuffix(text: string, spaceIndex: number): number { return spaceIndex; } +function isConnectiveSpacedWord(word: string): boolean { + return /^(?:and|or)$/i.test(word); +} + function readSpacedContinuation( text: string, spaceIndex: number, @@ -202,7 +206,7 @@ function readSpacedContinuation( const after = text[j]; if (after === separator) { - return isSpacedFilenameContinuation(word, prevToken, text, j) ? j : spaceIndex; + return isConnectiveSpacedWord(word) ? spaceIndex : j; } if (after === undefined || /[.,;:!?)'\]"]/.test(after)) { From f31ed2a41a4540a107a023eb51bf5396d3379727 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 07:55:23 +0300 Subject: [PATCH 39/53] fix(desktop): ignore equals in URL query parameters Only treat = as a path start boundary for assignment and flag forms, not for ?file= or &file= query values embedded in URLs. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 5 +++++ ui/desktop/src/utils/linkifyPaths.ts | 12 +++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 3f7eb033cb93..b6d43a4c44e9 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -215,6 +215,11 @@ describe('path linkification', () => { expect(findPaths('--output=/tmp/out')[0][1]).toBe('/tmp/out'); }); + it('does not linkify paths inside URL query values', () => { + expect(findPaths('See `https://host/download?file=/tmp/out`')).toHaveLength(0); + expect(findPaths('Visit example.com?file=/tmp/out')).toHaveLength(0); + }); + it('does not match URLs', () => { const matches = findPaths('Visit https://example.com/page for info'); expect(matches).toHaveLength(0); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 12eb18a0069c..b9c53693fca9 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -46,11 +46,21 @@ function isUrlPathAt(text: string, index: number): boolean { return scheme.length > 0 && /[a-zA-Z]/.test(scheme[0]); } +function isAssignmentEqualsStart(text: string, index: number): boolean { + if (text[index - 1] !== '=') return false; + let i = index - 2; + while (i >= 0 && /[a-zA-Z0-9_.$-]/.test(text[i])) { + i--; + } + return !(i >= 0 && (text[i] === '?' || text[i] === '&')); +} + function isCandidatePathStart(text: string, index: number, afterBlockComment: boolean): boolean { if (index === 0) return true; if (isUrlPathAt(text, index)) return false; const prev = text[index - 1]; - if (/[\s('"`[(,;=]/.test(prev)) return true; + if (prev === '=') return isAssignmentEqualsStart(text, index); + if (/[\s('"`[(,;]/.test(prev)) return true; return afterBlockComment; } From b3a3484e6fb109ec88a4e5838ea4f24c103e19bc Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 08:05:53 +0300 Subject: [PATCH 40/53] fix(desktop): preserve parenthesized filename suffixes with spaces Allow spaced and hyphenated parenthetical filename suffixes when an extension follows, while still rejecting short prose parentheticals like (temp) after paths. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 9 +++++++++ ui/desktop/src/utils/linkifyPaths.ts | 9 +++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index b6d43a4c44e9..be69ae15481b 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -268,6 +268,15 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/Users/me/Downloads/report (final).pdf'); }); + it('detects paths with spaced or hyphenated parenthesized suffixes', () => { + expect(findPaths('Saved /Users/me/Downloads/report (final copy).pdf')[0][1]).toBe( + '/Users/me/Downloads/report (final copy).pdf' + ); + expect(findPaths('Saved /tmp/report (final-draft).pdf')[0][1]).toBe( + '/tmp/report (final-draft).pdf' + ); + }); + it('detects paths with alphanumeric parenthesized filename suffixes', () => { const matches = findPaths('Saved /Users/me/Downloads/report (v2).pdf'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index b9c53693fca9..6b3631f32bf7 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -149,6 +149,11 @@ function isLinkLikeParent(parent: Parent | undefined): boolean { return parent?.type === 'link' || parent?.type === 'linkReference'; } +function isParenthesizedFilenameContent(content: string): boolean { + if (/^\d+$/.test(content)) return true; + return /^[a-zA-Z0-9]+(?:[ -][a-zA-Z0-9]+)*$/.test(content); +} + function readParenthesizedSuffix(text: string, spaceIndex: number): number { if (text[spaceIndex] !== ' ') return spaceIndex; @@ -164,7 +169,7 @@ function readParenthesizedSuffix(text: string, spaceIndex: number): number { if (i >= text.length) return spaceIndex; const content = text.slice(contentStart, i); - if (!/^[a-zA-Z0-9]+$/.test(content)) return spaceIndex; + if (!isParenthesizedFilenameContent(content)) return spaceIndex; i++; const afterParen = i; @@ -177,7 +182,7 @@ function readParenthesizedSuffix(text: string, spaceIndex: number): number { if (i > afterParen) { return i; } - if (/^[a-zA-Z0-9]{1,4}$/.test(content) && /\d/.test(content)) { + if (!/[ -]/.test(content) && /^[a-zA-Z0-9]{1,4}$/.test(content) && /\d/.test(content)) { return afterParen; } return spaceIndex; From f71168a9e8715d4c330124d213cf04daf2ee064a Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 08:19:15 +0300 Subject: [PATCH 41/53] fix(desktop): tighten spaced path continuation heuristics Accept extension-bearing spaced filename segments regardless of basename case, and only allow bare 4+ digit continuations after capitalized basenames when the path ends or hits sentence punctuation. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 20 +++++++++++++++++ ui/desktop/src/utils/linkifyPaths.ts | 27 ++++++++++++++--------- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index be69ae15481b..eff1b07aa32e 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -111,6 +111,18 @@ describe('path linkification', () => { expect(findPaths('Saved /tmp/project notes.txt')[0][1]).toBe('/tmp/project notes.txt'); }); + it('detects extension-bearing spaced filenames after titlecase basenames', () => { + expect(findPaths('Saved /tmp/Project notes.txt')[0][1]).toBe('/tmp/Project notes.txt'); + }); + + it('detects extension-bearing spaced filenames after acronym basenames', () => { + expect(findPaths('Saved /tmp/API response.json')[0][1]).toBe('/tmp/API response.json'); + }); + + it('detects multi-dot extension-bearing spaced filenames', () => { + expect(findPaths('Saved /tmp/My draft.final.md')[0][1]).toBe('/tmp/My draft.final.md'); + }); + it('detects paths with multiple lowercase spaced filename words', () => { expect(findPaths('Saved /tmp/project notes draft.txt')[0][1]).toBe( '/tmp/project notes draft.txt' @@ -337,6 +349,14 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/tmp/out'); }); + it('does not extend paths with bare 4+ digit prose counts', () => { + expect(findPaths('Created /tmp/out 2026 files')[0][1]).toBe('/tmp/out'); + }); + + it('does not extend capitalized basenames with bare 4+ digit prose counts', () => { + expect(findPaths('Created /tmp/Report 2026 files')[0][1]).toBe('/tmp/Report'); + }); + it('does not extend capitalized paths with short numeric counts', () => { expect(findPaths('Created /tmp/Out 2 files')[0][1]).toBe('/tmp/Out'); }); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 6b3631f32bf7..2047d710a2f2 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -79,15 +79,25 @@ function isParenthesizedSuffix(token: string): boolean { return /^\([^)]+\)$/.test(token); } -function isPathLikeSpacedWord(word: string, prevToken: string): boolean { +function hasFileExtension(word: string): boolean { + return /\.[A-Za-z0-9]+$/.test(word); +} + +function canContinueWithNumericWord(word: string, prevToken: string, after: string | undefined): boolean { + return ( + word.length >= 4 && + /^[A-Z]/.test(prevToken) && + (after === undefined || isPathTerminator(after)) + ); +} + +function isPathLikeSpacedWord(word: string, prevToken: string, after: string | undefined): boolean { if (isParenthesizedSuffix(prevToken)) return false; if (/^\d+$/.test(word)) { - return word.length >= 4; + return canContinueWithNumericWord(word, prevToken, after); } + if (hasFileExtension(word)) return true; if (/^[A-Z]/.test(word)) { - if (/\.[A-Za-z0-9]+$/.test(word)) { - return /^[A-Z]/.test(prevToken); - } return ( /^[A-Z]/.test(prevToken) && word.length >= 4 && @@ -95,9 +105,6 @@ function isPathLikeSpacedWord(word: string, prevToken: string): boolean { prevToken.length <= 3 ); } - if (/\.[A-Za-z0-9]+$/.test(word) && /^[a-z]/.test(prevToken)) { - return true; - } return ( word.length >= 4 && prevToken.length >= 2 && @@ -117,7 +124,7 @@ function readExtensionWordAhead(text: string, fromIndex: number): boolean { } if (j === i) return false; const word = text.slice(i, j).replace(TRAILING_PUNCTUATION_RE, ''); - if (/\.[A-Za-z0-9]+$/.test(word) && /^[a-z]/.test(word)) return true; + if (hasFileExtension(word) && /^[a-z]/.test(word)) return true; if (!/^[a-z][a-z0-9]*$/.test(word)) return false; i = j; } @@ -137,7 +144,7 @@ function isSpacedFilenameContinuation( (/^[a-z][a-z0-9]*$/.test(inner) || /^\d+$/.test(inner)) ); } - if (isPathLikeSpacedWord(word, prevToken)) return true; + if (isPathLikeSpacedWord(word, prevToken, text[endIndex])) return true; return ( /^[a-z][a-z0-9]*$/.test(word) && /^[a-z]/.test(prevToken) && From d69f581ef196686d84d8f3ffc3d3bb3a237d5e6a Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 08:36:53 +0300 Subject: [PATCH 42/53] fix(ui): linkify inline parenthesized filename suffixes Support filenames like report(1).pdf and foo(bar).txt in path detection while rejecting inline prose parentheticals without extensions. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 10 +++++++ ui/desktop/src/utils/linkifyPaths.ts | 36 +++++++++++++++-------- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index eff1b07aa32e..e3c3be072525 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -103,6 +103,16 @@ describe('path linkification', () => { expect(findPaths('Saved /tmp/report[1]')[0][1]).toBe('/tmp/report[1]'); }); + it('detects paths with inline parenthesized suffixes in filenames', () => { + expect(findPaths('Saved /tmp/report(1).pdf')[0][1]).toBe('/tmp/report(1).pdf'); + expect(findPaths('Saved /tmp/foo(bar).txt')[0][1]).toBe('/tmp/foo(bar).txt'); + }); + + it('does not absorb inline parenthetical prose without extension', () => { + expect(findPaths('Created /tmp/out(temporary) for debugging')[0][1]).toBe('/tmp/out'); + expect(findPaths('Created /tmp/report(temp) for debugging')[0][1]).toBe('/tmp/report'); + }); + it('detects paths with bracketed suffixes after spaced filename segments', () => { expect(findPaths('Saved /tmp/My report[1].pdf')[0][1]).toBe('/tmp/My report[1].pdf'); }); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 2047d710a2f2..3b1d1c2f7495 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -161,22 +161,17 @@ function isParenthesizedFilenameContent(content: string): boolean { return /^[a-zA-Z0-9]+(?:[ -][a-zA-Z0-9]+)*$/.test(content); } -function readParenthesizedSuffix(text: string, spaceIndex: number): number { - if (text[spaceIndex] !== ' ') return spaceIndex; - - let i = spaceIndex + 1; - if (text[i] !== '(') return spaceIndex; - - i++; +function readParenthesizedContent(text: string, openIndex: number): number { + let i = openIndex + 1; const contentStart = i; while (i < text.length && text[i] !== ')') { - if (text[i] === '(') return spaceIndex; + if (text[i] === '(') return openIndex; i++; } - if (i >= text.length) return spaceIndex; + if (i >= text.length) return openIndex; const content = text.slice(contentStart, i); - if (!isParenthesizedFilenameContent(content)) return spaceIndex; + if (!isParenthesizedFilenameContent(content)) return openIndex; i++; const afterParen = i; @@ -192,7 +187,19 @@ function readParenthesizedSuffix(text: string, spaceIndex: number): number { if (!/[ -]/.test(content) && /^[a-zA-Z0-9]{1,4}$/.test(content) && /\d/.test(content)) { return afterParen; } - return spaceIndex; + return openIndex; +} + +function readParenthesizedInlineSuffix(text: string, parenIndex: number): number { + if (text[parenIndex] !== '(') return parenIndex; + return readParenthesizedContent(text, parenIndex); +} + +function readParenthesizedSuffix(text: string, spaceIndex: number): number { + if (text[spaceIndex] !== ' ') return spaceIndex; + if (text[spaceIndex + 1] !== '(') return spaceIndex; + const end = readParenthesizedContent(text, spaceIndex + 1); + return end > spaceIndex + 1 ? end : spaceIndex; } function isConnectiveSpacedWord(word: string): boolean { @@ -278,6 +285,11 @@ function readSegment(text: string, start: number, separator: Separator): { end: i = bracketEnd; continue; } + const parenEnd = readParenthesizedInlineSuffix(text, i); + if (parenEnd > i) { + i = parenEnd; + continue; + } if ( isFilenamePunctuation(text[i]) && i + 1 < text.length && @@ -304,7 +316,7 @@ function readSegment(text: string, start: number, separator: Separator): { end: } function isPathTerminator(char: string): boolean { - return /[.,;:!?'"`)\]]/.test(char); + return /[.,;:!?'"`()\]]/.test(char); } function isPathBoundary(char: string): boolean { From 2c7ac42887773d0fd01697c3c4d3e0bf37cd36fc Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 08:53:19 +0300 Subject: [PATCH 43/53] fix(desktop): allow dots and underscores in parenthesized filename suffixes Versioned download names like (v1.2) or (final_v2) were rejected by isParenthesizedFilenameContent, so spaced paths stopped at the basename. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 24 +++++++++++++++++++++++ ui/desktop/src/utils/linkifyPaths.ts | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index e3c3be072525..734acd4802d0 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -305,6 +305,30 @@ describe('path linkification', () => { expect(matches[0][1]).toBe('/Users/me/Downloads/report (v2).pdf'); }); + it('detects paths with dotted version parenthesized suffixes', () => { + expect(findPaths('Saved /tmp/report (v1.2).pdf')[0][1]).toBe('/tmp/report (v1.2).pdf'); + expect(findPaths('Saved /tmp/report (v1.2.3).pdf')[0][1]).toBe('/tmp/report (v1.2.3).pdf'); + expect(findPaths('Saved /tmp/report(v1.2).pdf')[0][1]).toBe('/tmp/report(v1.2).pdf'); + }); + + it('detects paths with underscored parenthesized suffixes', () => { + expect(findPaths('Saved /tmp/report (final_v2).pdf')[0][1]).toBe('/tmp/report (final_v2).pdf'); + expect(findPaths('Saved /tmp/report (draft_v2).pdf')[0][1]).toBe('/tmp/report (draft_v2).pdf'); + expect(findPaths('Saved /tmp/report(final_v2).pdf')[0][1]).toBe('/tmp/report(final_v2).pdf'); + }); + + it('detects paths with spaced version parenthesized suffixes', () => { + expect(findPaths('Saved /tmp/report (final copy v2).pdf')[0][1]).toBe( + '/tmp/report (final copy v2).pdf' + ); + }); + + it('does not absorb dotted parenthetical prose without extension', () => { + expect(findPaths('Created /tmp/out (temporary) for debugging')[0][1]).toBe('/tmp/out'); + expect(findPaths('Created /tmp/report (temp) for debugging')[0][1]).toBe('/tmp/report'); + expect(findPaths('Created /tmp/out(temporary) for debugging')[0][1]).toBe('/tmp/out'); + }); + it('preserves closing parens in parenthesized paths without extension', () => { const matches = findPaths('Saved /Users/me/Downloads/report (1)'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 3b1d1c2f7495..22fb03cb9920 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -158,7 +158,7 @@ function isLinkLikeParent(parent: Parent | undefined): boolean { function isParenthesizedFilenameContent(content: string): boolean { if (/^\d+$/.test(content)) return true; - return /^[a-zA-Z0-9]+(?:[ -][a-zA-Z0-9]+)*$/.test(content); + return /^[a-zA-Z0-9._]+(?:[ -][a-zA-Z0-9._]+)*$/.test(content); } function readParenthesizedContent(text: string, openIndex: number): number { From 032955f478eef24b7a2c6dd3d35f8b7726e3ca91 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 09:08:04 +0300 Subject: [PATCH 44/53] fix(ui): tighten path linkification for diagnostics and parentheticals Strip line:column suffixes from extensionless diagnostic filenames like Dockerfile and Makefile, and require real extension evidence after word parentheticals so prose like "(temporary)." is not absorbed. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 12 ++++++++++++ ui/desktop/src/utils/linkifyPaths.ts | 21 +++++++++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 734acd4802d0..2b798cbb64bf 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -151,6 +151,12 @@ describe('path linkification', () => { expect(findPaths('See /project/README.md:3 for details')[0][1]).toBe('/project/README.md'); }); + it('strips line and column suffixes from extensionless diagnostic filenames', () => { + expect(findPaths('error at /workspace/Dockerfile:12')[0][1]).toBe('/workspace/Dockerfile'); + expect(findPaths('error at /workspace/Makefile:8:1')[0][1]).toBe('/workspace/Makefile'); + expect(findPaths('See /project/README:5 for details')[0][1]).toBe('/project/README'); + }); + it('preserves colon-number suffixes in filenames', () => { expect(findPaths('Saved /tmp/snapshot:1')[0][1]).toBe('/tmp/snapshot:1'); expect(findPaths('Saved /tmp/2026-06-18T18:30:00')[0][1]).toBe('/tmp/2026-06-18T18:30:00'); @@ -173,6 +179,12 @@ describe('path linkification', () => { expect(findPaths('Created /tmp/out (temp) for debugging')[0][1]).toBe('/tmp/out'); }); + it('does not absorb parentheticals followed only by sentence punctuation', () => { + expect(findPaths('Created /tmp/out (temporary).')[0][1]).toBe('/tmp/out'); + expect(findPaths('Created /tmp/report (temp).')[0][1]).toBe('/tmp/report'); + expect(findPaths('Created /tmp/out(temporary).')[0][1]).toBe('/tmp/out'); + }); + it('detects paths with dots and underscores', () => { const matches = findPaths('Read /home/user/.env.local'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 22fb03cb9920..aece69089813 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -19,10 +19,18 @@ function stripTrailingPunctuation(path: string): string { } function stripLineColumnSuffix(path: string): string { - const match = path.match(/\.[A-Za-z0-9]+(:\d+(?::\d+)?)$/); - if (match) { - return path.slice(0, -match[1].length); + const extMatch = path.match(/\.[A-Za-z0-9]+(:\d+(?::\d+)?)$/); + if (extMatch) { + return path.slice(0, -extMatch[1].length); } + + const lastSep = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + const basename = path.slice(lastSep + 1); + const lineColMatch = basename.match(/^([A-Z][A-Za-z0-9_-]*)(:\d+(?::\d+)?)$/); + if (lineColMatch) { + return path.slice(0, -lineColMatch[2].length); + } + return path; } @@ -161,6 +169,11 @@ function isParenthesizedFilenameContent(content: string): boolean { return /^[a-zA-Z0-9._]+(?:[ -][a-zA-Z0-9._]+)*$/.test(content); } +function hasFilenameSuffixAfterParen(text: string, afterParen: number, endIndex: number): boolean { + const suffix = text.slice(afterParen, endIndex).replace(TRAILING_PUNCTUATION_RE, ''); + return suffix.length > 0 && hasFileExtension(suffix); +} + function readParenthesizedContent(text: string, openIndex: number): number { let i = openIndex + 1; const contentStart = i; @@ -181,7 +194,7 @@ function readParenthesizedContent(text: string, openIndex: number): number { if (/^\d+$/.test(content)) { return i; } - if (i > afterParen) { + if (i > afterParen && hasFilenameSuffixAfterParen(text, afterParen, i)) { return i; } if (!/[ -]/.test(content) && /^[a-zA-Z0-9]{1,4}$/.test(content) && /\d/.test(content)) { From a321ea3042bfb4bf815056355452a0f0f1bdbcea Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 09:19:51 +0300 Subject: [PATCH 45/53] fix(ui): reject URL fragment/path params and strip lowercase diagnostic suffixes Treat # and ; like query delimiters before assignment-style path starts so paths in URL fragments and path parameters are not linkified. Strip :line suffixes from known lowercase extensionless diagnostic filenames such as justfile and dockerfile without affecting colon-in-filename cases like snapshot:1. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 17 +++++++++++++ ui/desktop/src/utils/linkifyPaths.ts | 29 ++++++++++++++++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 2b798cbb64bf..e56d2b1b4ab8 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -155,6 +155,13 @@ describe('path linkification', () => { expect(findPaths('error at /workspace/Dockerfile:12')[0][1]).toBe('/workspace/Dockerfile'); expect(findPaths('error at /workspace/Makefile:8:1')[0][1]).toBe('/workspace/Makefile'); expect(findPaths('See /project/README:5 for details')[0][1]).toBe('/project/README'); + expect(findPaths('error at /crates/goose-sdk/justfile:12')[0][1]).toBe( + '/crates/goose-sdk/justfile' + ); + expect(findPaths('error at /repo/dockerfile:3:1')[0][1]).toBe('/repo/dockerfile'); + expect(findPaths('error at /repo/makefile:42')[0][1]).toBe('/repo/makefile'); + expect(findPaths('error at /ci/Jenkinsfile:7')[0][1]).toBe('/ci/Jenkinsfile'); + expect(findPaths('error at /app/Procfile:2')[0][1]).toBe('/app/Procfile'); }); it('preserves colon-number suffixes in filenames', () => { @@ -252,6 +259,16 @@ describe('path linkification', () => { it('does not linkify paths inside URL query values', () => { expect(findPaths('See `https://host/download?file=/tmp/out`')).toHaveLength(0); expect(findPaths('Visit example.com?file=/tmp/out')).toHaveLength(0); + expect(findPaths('Visit example.com?artifact=/tmp/out.log')).toHaveLength(0); + expect(findPaths('Visit example.com?path=/tmp/out&other=1')).toHaveLength(0); + }); + + it('does not linkify paths inside URL fragment or path-parameter values', () => { + expect(findPaths('See https://host/page#file=/tmp/out')).toHaveLength(0); + expect(findPaths('See https://host/download;file=/tmp/out')).toHaveLength(0); + expect(findPaths('See https://host/page#artifact=/tmp/out.log')).toHaveLength(0); + expect(findPaths('See https://host/download;output=/tmp/out')).toHaveLength(0); + expect(findPaths('See https://host/page#path=/tmp/out&other=1')).toHaveLength(0); }); it('does not match URLs', () => { diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index aece69089813..b7b6d221a5c7 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -6,6 +6,25 @@ const OPEN_FILE_PROTOCOL = 'open-file://'; const TRAILING_PUNCTUATION_RE = /[.,;:!?'"]+$/; +const EXTENSIONLESS_DIAGNOSTIC_FILENAMES = new Set([ + 'dockerfile', + 'justfile', + 'makefile', + 'jenkinsfile', + 'rakefile', + 'gemfile', + 'procfile', + 'vagrantfile', + 'brewfile', + 'fastfile', + 'containerfile', + 'snakefile', + 'cmakelists', + 'gnumakefile', +]); + +const URL_PARAM_DELIMITERS = new Set(['?', '&', '#', ';']); + type PathMatch = [index: number, path: string]; type Separator = '/' | '\\'; @@ -26,9 +45,13 @@ function stripLineColumnSuffix(path: string): string { const lastSep = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); const basename = path.slice(lastSep + 1); - const lineColMatch = basename.match(/^([A-Z][A-Za-z0-9_-]*)(:\d+(?::\d+)?)$/); + const lineColMatch = basename.match(/^([A-Za-z][A-Za-z0-9_-]*)(:\d+(?::\d+)?)$/); if (lineColMatch) { - return path.slice(0, -lineColMatch[2].length); + const name = lineColMatch[1]; + const suffix = lineColMatch[2]; + if (/^[A-Z]/.test(name) || EXTENSIONLESS_DIAGNOSTIC_FILENAMES.has(name.toLowerCase())) { + return path.slice(0, -suffix.length); + } } return path; @@ -60,7 +83,7 @@ function isAssignmentEqualsStart(text: string, index: number): boolean { while (i >= 0 && /[a-zA-Z0-9_.$-]/.test(text[i])) { i--; } - return !(i >= 0 && (text[i] === '?' || text[i] === '&')); + return !(i >= 0 && URL_PARAM_DELIMITERS.has(text[i])); } function isCandidatePathStart(text: string, index: number, afterBlockComment: boolean): boolean { From bc1dd15100069035b576de898c8f54199b66cb32 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 09:33:58 +0300 Subject: [PATCH 46/53] fix(ui): linkify paths with parenthesized directory segments Accept spaced path continuations when a parenthesized directory suffix like (x86) or (Beta) appears before the next separator, so Windows and Unix paths such as C:\Program Files (x86)\Goose linkify fully. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 7 +++++++ ui/desktop/src/utils/linkifyPaths.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index e56d2b1b4ab8..5a99f06e6c05 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -239,6 +239,13 @@ describe('path linkification', () => { expect(findPaths('See C:\\Program Files\\Goose')[0][1]).toBe('C:\\Program Files\\Goose'); }); + it('detects paths with parenthesized spaced directory names before separators', () => { + expect(findPaths('See C:\\Program Files (x86)\\Goose')[0][1]).toBe( + 'C:\\Program Files (x86)\\Goose' + ); + expect(findPaths('/Users/me/Apps (Beta)/app')[0][1]).toBe('/Users/me/Apps (Beta)/app'); + }); + it('includes spaced folder names before sentence punctuation', () => { const matches = findPaths('Saved to /home/user/my documents.'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index b7b6d221a5c7..9213f4a04c63 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -211,6 +211,9 @@ function readParenthesizedContent(text: string, openIndex: number): number { i++; const afterParen = i; + if (text[i] === '/' || text[i] === '\\') { + return afterParen; + } while (i < text.length && isPathChar(text[i])) { i++; } @@ -238,6 +241,18 @@ function readParenthesizedSuffix(text: string, spaceIndex: number): number { return end > spaceIndex + 1 ? end : spaceIndex; } +function hasParenthesizedDirectoryBeforeSeparator( + text: string, + fromIndex: number, + separator: Separator +): boolean { + let i = fromIndex; + while (i < text.length && text[i] === ' ') i++; + if (text[i] !== '(') return false; + const end = readParenthesizedContent(text, i); + return end > i && text[end] === separator; +} + function isConnectiveSpacedWord(word: string): boolean { return /^(?:and|or)$/i.test(word); } @@ -282,6 +297,7 @@ function readSpacedContinuation( if (after === ' ') { const rest = text.slice(j).trimStart(); if (rest.startsWith(separator)) return spaceIndex; + if (hasParenthesizedDirectoryBeforeSeparator(text, j, separator)) return j; } return isSpacedFilenameContinuation(word, prevToken, text, j) ? j : spaceIndex; } From c9823ef9ec54c9ff8361a353f3bf157f7453a64b Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 09:52:34 +0300 Subject: [PATCH 47/53] fix(ui): reject bracketed URL query keys and uppercase extension lookahead Extend query-param key scanning in isAssignmentEqualsStart to include brackets and percent-encoding so paths in values like file[]=/tmp/out are not linkified. Accept extension-bearing spaced words regardless of case in readExtensionWordAhead to match isPathLikeSpacedWord behavior. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 34 +++++++++++++++++++++++ ui/desktop/src/utils/linkifyPaths.ts | 8 ++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 5a99f06e6c05..fb576fe78356 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -142,6 +142,27 @@ describe('path linkification', () => { ); }); + it('detects paths with uppercase extension words after spaced segments', () => { + expect(findPaths('Saved /tmp/project notes Draft.txt')[0][1]).toBe( + '/tmp/project notes Draft.txt' + ); + expect(findPaths('Saved /tmp/project notes FINAL.pdf')[0][1]).toBe( + '/tmp/project notes FINAL.pdf' + ); + expect(findPaths('Saved /tmp/My Project Report.DOCX')[0][1]).toBe( + '/tmp/My Project Report.DOCX' + ); + }); + + it('detects paths with mixed-case multi-word filenames ending in extension', () => { + expect(findPaths('Saved /tmp/project notes draft Final.txt')[0][1]).toBe( + '/tmp/project notes draft Final.txt' + ); + expect(findPaths('Saved /tmp/project notes Draft.txt.')[0][1]).toBe( + '/tmp/project notes Draft.txt' + ); + }); + it('strips trailing line and column suffixes from paths', () => { expect(findPaths('error at /workspace/src/lib.rs:42:7')[0][1]).toBe('/workspace/src/lib.rs'); expect(findPaths('error at /workspace/src/lib.rs:42')[0][1]).toBe('/workspace/src/lib.rs'); @@ -270,6 +291,19 @@ describe('path linkification', () => { expect(findPaths('Visit example.com?path=/tmp/out&other=1')).toHaveLength(0); }); + it('does not linkify paths inside URL query values with bracketed keys', () => { + expect(findPaths('See https://host/download?file[]=/tmp/out')).toHaveLength(0); + expect(findPaths('See https://host/download?items[0]=/tmp/out')).toHaveLength(0); + expect(findPaths('See https://host/download?data[key]=/tmp/out.log')).toHaveLength(0); + expect(findPaths('Visit example.com?file[]=/tmp/out&other=1')).toHaveLength(0); + }); + + it('does not linkify paths inside URL query values with percent-encoded keys', () => { + expect(findPaths('See https://host/download?file%5B%5D=/tmp/out')).toHaveLength(0); + expect(findPaths('See https://host/download?items%5B0%5D=/tmp/out.log')).toHaveLength(0); + expect(findPaths('Visit example.com?file%5B%5D=/tmp/out')).toHaveLength(0); + }); + it('does not linkify paths inside URL fragment or path-parameter values', () => { expect(findPaths('See https://host/page#file=/tmp/out')).toHaveLength(0); expect(findPaths('See https://host/download;file=/tmp/out')).toHaveLength(0); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 9213f4a04c63..f43df9456537 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -77,10 +77,14 @@ function isUrlPathAt(text: string, index: number): boolean { return scheme.length > 0 && /[a-zA-Z]/.test(scheme[0]); } +function isQueryParamKeyChar(char: string): boolean { + return /[a-zA-Z0-9_.$\[\]%-]/.test(char); +} + function isAssignmentEqualsStart(text: string, index: number): boolean { if (text[index - 1] !== '=') return false; let i = index - 2; - while (i >= 0 && /[a-zA-Z0-9_.$-]/.test(text[i])) { + while (i >= 0 && isQueryParamKeyChar(text[i])) { i--; } return !(i >= 0 && URL_PARAM_DELIMITERS.has(text[i])); @@ -155,7 +159,7 @@ function readExtensionWordAhead(text: string, fromIndex: number): boolean { } if (j === i) return false; const word = text.slice(i, j).replace(TRAILING_PUNCTUATION_RE, ''); - if (hasFileExtension(word) && /^[a-z]/.test(word)) return true; + if (hasFileExtension(word)) return true; if (!/^[a-z][a-z0-9]*$/.test(word)) return false; i = j; } From ad9db5af9072a2c1aee256dbc8884e5b82d133e3 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 10:04:03 +0300 Subject: [PATCH 48/53] fix(ui): allow titlecase basename extension lookahead Let lowercase spaced filename words continue after titlecase or acronym basenames when readExtensionWordAhead proves a later extension, matching two-word cases already handled via hasFileExtension. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 24 +++++++++++++++++++++++ ui/desktop/src/utils/linkifyPaths.ts | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index fb576fe78356..893edb5f6f08 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -154,6 +154,30 @@ describe('path linkification', () => { ); }); + it('detects multi-word spaced filenames after titlecase basenames', () => { + expect(findPaths('Saved /tmp/Project notes draft.txt')[0][1]).toBe( + '/tmp/Project notes draft.txt' + ); + expect(findPaths('Saved /tmp/Project notes draft.txt.')[0][1]).toBe( + '/tmp/Project notes draft.txt' + ); + expect(findPaths('Saved /tmp/Project notes Draft.txt')[0][1]).toBe( + '/tmp/Project notes Draft.txt' + ); + }); + + it('detects multi-word spaced filenames after acronym basenames', () => { + expect(findPaths('Saved /tmp/API response data.json')[0][1]).toBe( + '/tmp/API response data.json' + ); + expect(findPaths('Saved /tmp/API response data.json.')[0][1]).toBe( + '/tmp/API response data.json' + ); + expect(findPaths('Saved /tmp/API response DATA.json')[0][1]).toBe( + '/tmp/API response DATA.json' + ); + }); + it('detects paths with mixed-case multi-word filenames ending in extension', () => { expect(findPaths('Saved /tmp/project notes draft Final.txt')[0][1]).toBe( '/tmp/project notes draft Final.txt' diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index f43df9456537..55a217f7b990 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -182,7 +182,7 @@ function isSpacedFilenameContinuation( if (isPathLikeSpacedWord(word, prevToken, text[endIndex])) return true; return ( /^[a-z][a-z0-9]*$/.test(word) && - /^[a-z]/.test(prevToken) && + /^[A-Za-z]/.test(prevToken) && readExtensionWordAhead(text, endIndex) ); } From d5619ded04c90b2f7b410421dc9aa35f8c80fa1f Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 10:34:49 +0300 Subject: [PATCH 49/53] fix(ui): include hyphen in URL query param key scan Move hyphen to the start of the isQueryParamKeyChar character class so it cannot be misread as a range operator, and add tests for hyphenated query keys like file-name= and content-type=. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 7 +++++++ ui/desktop/src/utils/linkifyPaths.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 893edb5f6f08..375978f3eabe 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -336,6 +336,13 @@ describe('path linkification', () => { expect(findPaths('See https://host/page#path=/tmp/out&other=1')).toHaveLength(0); }); + it('does not linkify paths inside URL query values with hyphenated keys', () => { + expect(findPaths('See https://host/download?file-name=/tmp/out')).toHaveLength(0); + expect(findPaths('See https://host/download?content-type=/tmp/out.log')).toHaveLength(0); + expect(findPaths('See https://host/download?x-custom-param=/tmp/out')).toHaveLength(0); + expect(findPaths('Visit example.com?file-name=/tmp/out&other=1')).toHaveLength(0); + }); + it('does not match URLs', () => { const matches = findPaths('Visit https://example.com/page for info'); expect(matches).toHaveLength(0); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 55a217f7b990..d085fac0f468 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -78,7 +78,7 @@ function isUrlPathAt(text: string, index: number): boolean { } function isQueryParamKeyChar(char: string): boolean { - return /[a-zA-Z0-9_.$\[\]%-]/.test(char); + return /[-a-zA-Z0-9_.$\[\]%]/.test(char); } function isAssignmentEqualsStart(text: string, index: number): boolean { From a53493d291a55e9dfb589330098d47e7579e7033 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 10:44:40 +0300 Subject: [PATCH 50/53] fix(ui): allow apostrophe and Unicode in parenthesized filename suffixes Expand isParenthesizedFilenameContent to accept characters already valid in path segments via isPathChar, plus spaces, hyphens, and apostrophes. Extension evidence in readParenthesizedContent still blocks prose like (temporary) without a real filename suffix. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 22 ++++++++++++++++++++++ ui/desktop/src/utils/linkifyPaths.ts | 15 ++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 375978f3eabe..45db543742b8 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -460,6 +460,28 @@ describe('path linkification', () => { expect(matches[0][1]).toBe("/tmp/it's.txt"); }); + it('detects paths with apostrophes in parenthesized filename suffixes', () => { + expect(findPaths("Saved /tmp/report (John's copy).pdf")[0][1]).toBe( + "/tmp/report (John's copy).pdf" + ); + expect(findPaths("Saved /tmp/report(John's).pdf")[0][1]).toBe("/tmp/report(John's).pdf"); + }); + + it('detects paths with Unicode in parenthesized filename suffixes', () => { + expect(findPaths('Saved /tmp/report (最終).pdf')[0][1]).toBe('/tmp/report (最終).pdf'); + expect(findPaths('Saved /tmp/report(最終).pdf')[0][1]).toBe('/tmp/report(最終).pdf'); + expect(findPaths('Saved /tmp/report (コピー v2).pdf')[0][1]).toBe( + '/tmp/report (コピー v2).pdf' + ); + }); + + it('still rejects prose parentheticals without extension evidence', () => { + expect(findPaths('Created /tmp/out (temporary) for debugging')[0][1]).toBe('/tmp/out'); + expect(findPaths('Created /tmp/report (temp) for debugging')[0][1]).toBe('/tmp/report'); + expect(findPaths('Created /tmp/out (temporary).')[0][1]).toBe('/tmp/out'); + expect(findPaths('Created /tmp/report (see notes) for review')[0][1]).toBe('/tmp/report'); + }); + it('detects paths with colons in timestamped filenames', () => { const matches = findPaths('Saved /tmp/2026-06-18T18:30:00.log'); expect(matches).toHaveLength(1); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index d085fac0f468..22e1b189e9de 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -191,9 +191,22 @@ function isLinkLikeParent(parent: Parent | undefined): boolean { return parent?.type === 'link' || parent?.type === 'linkReference'; } +function isParenthesizedFilenameChar(char: string): boolean { + if (char === ' ' || char === '-' || char === "'") return true; + return isPathChar(char); +} + function isParenthesizedFilenameContent(content: string): boolean { if (/^\d+$/.test(content)) return true; - return /^[a-zA-Z0-9._]+(?:[ -][a-zA-Z0-9._]+)*$/.test(content); + if (content.length === 0) return false; + let hasSubstantive = false; + for (const char of content) { + if (!isParenthesizedFilenameChar(char)) return false; + if (/[\p{L}\p{N}a-zA-Z0-9._]/u.test(char)) { + hasSubstantive = true; + } + } + return hasSubstantive; } function hasFilenameSuffixAfterParen(text: string, afterParen: number, endIndex: number): boolean { From 7aad701d84ec47f13f036fdb65d32bee614ba6b7 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 10:45:19 +0300 Subject: [PATCH 51/53] fix(ui): resolve ESLint escape and support Unicode parenthesized paths Remove unnecessary \[ escape in isQueryParamKeyChar regex and extend parenthesized filename detection for apostrophes and Unicode content. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 22e1b189e9de..cb0cc2aea984 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -78,7 +78,7 @@ function isUrlPathAt(text: string, index: number): boolean { } function isQueryParamKeyChar(char: string): boolean { - return /[-a-zA-Z0-9_.$\[\]%]/.test(char); + return /[-a-zA-Z0-9_.$[\]%]/.test(char); } function isAssignmentEqualsStart(text: string, index: number): boolean { From 535be0762ae6cbaa4a5342894097f7aa7ac0eef2 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 11:00:49 +0300 Subject: [PATCH 52/53] fix(ui): stop two-char basename paths from absorbing prose Require extension, separator, or extension-ahead evidence before the two-char basename spaced-word fallback, and keep determiner-led folder names like "my documents" working via an explicit rule. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 12 +++++++ ui/desktop/src/utils/linkifyPaths.ts | 41 +++++++++++++++++++---- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index 45db543742b8..ea3c8132db42 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -271,6 +271,18 @@ describe('path linkification', () => { expect(findPaths('Check /tmp/log file for details')[0][1]).toBe('/tmp/log'); }); + it('does not extend two-char basenames with following prose', () => { + expect(findPaths('Created /tmp/ui successfully.')[0][1]).toBe('/tmp/ui'); + expect(findPaths('Check /tmp/go output')[0][1]).toBe('/tmp/go'); + expect(findPaths('Created /tmp/ui successfully for review')[0][1]).toBe('/tmp/ui'); + expect(findPaths('Check /tmp/go output please')[0][1]).toBe('/tmp/go'); + }); + + it('still detects determiner-led spaced folder names after two-char tokens', () => { + expect(findPaths('Output in /home/user/my documents')[0][1]).toBe('/home/user/my documents'); + expect(findPaths('Saved /tmp/my backup data')[0][1]).toBe('/tmp/my backup'); + }); + it('does not extend paths through connective prose before another slash', () => { const matches = findPaths('Review /tmp/output and/or /tmp/logs'); expect(matches[0][1]).toBe('/tmp/output'); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index cb0cc2aea984..251545dbc945 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -126,7 +126,24 @@ function canContinueWithNumericWord(word: string, prevToken: string, after: stri ); } -function isPathLikeSpacedWord(word: string, prevToken: string, after: string | undefined): boolean { +function hasFollowingSeparator(text: string, endIndex: number, separator: Separator): boolean { + let i = endIndex; + while (i < text.length && text[i] === ' ') i++; + return i < text.length && text[i] === separator; +} + +function isDeterminerLedSpacedFilename(prevToken: string, word: string): boolean { + return prevToken === 'my' && /^[a-z][a-z0-9]*$/.test(word) && word.length >= 4; +} + +function isPathLikeSpacedWord( + word: string, + prevToken: string, + after: string | undefined, + text: string, + endIndex: number, + separator: Separator +): boolean { if (isParenthesizedSuffix(prevToken)) return false; if (/^\d+$/.test(word)) { return canContinueWithNumericWord(word, prevToken, after); @@ -140,12 +157,18 @@ function isPathLikeSpacedWord(word: string, prevToken: string, after: string | u prevToken.length <= 3 ); } - return ( + if ( word.length >= 4 && prevToken.length >= 2 && prevToken.length <= 2 && !prevToken.includes('.') - ); + ) { + return ( + readExtensionWordAhead(text, endIndex) || + hasFollowingSeparator(text, endIndex, separator) + ); + } + return false; } function readExtensionWordAhead(text: string, fromIndex: number): boolean { @@ -170,7 +193,8 @@ function isSpacedFilenameContinuation( word: string, prevToken: string, text: string, - endIndex: number + endIndex: number, + separator: Separator ): boolean { if (/^\[[^\]]+\]$/.test(word)) { const inner = word.slice(1, -1); @@ -179,7 +203,10 @@ function isSpacedFilenameContinuation( (/^[a-z][a-z0-9]*$/.test(inner) || /^\d+$/.test(inner)) ); } - if (isPathLikeSpacedWord(word, prevToken, text[endIndex])) return true; + if (isDeterminerLedSpacedFilename(prevToken, word)) return true; + if (isPathLikeSpacedWord(word, prevToken, text[endIndex], text, endIndex, separator)) { + return true; + } return ( /^[a-z][a-z0-9]*$/.test(word) && /^[A-Za-z]/.test(prevToken) && @@ -307,7 +334,7 @@ function readSpacedContinuation( } if (after === undefined || /[.,;:!?)'\]"]/.test(after)) { - return isSpacedFilenameContinuation(word, prevToken, text, j) ? j : spaceIndex; + return isSpacedFilenameContinuation(word, prevToken, text, j, separator) ? j : spaceIndex; } if (after !== undefined && /\s/.test(after)) { @@ -316,7 +343,7 @@ function readSpacedContinuation( if (rest.startsWith(separator)) return spaceIndex; if (hasParenthesizedDirectoryBeforeSeparator(text, j, separator)) return j; } - return isSpacedFilenameContinuation(word, prevToken, text, j) ? j : spaceIndex; + return isSpacedFilenameContinuation(word, prevToken, text, j, separator) ? j : spaceIndex; } return spaceIndex; From cc8048e457cd47d9f82a31aff2479ea89bd2d595 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 19 Jun 2026 11:14:26 +0300 Subject: [PATCH 53/53] fix(ui): reject URL path assignments and hyphenated spaced filenames Reject linkifying paths after = when the slash belongs to a URL path segment, and allow hyphens/underscores in intermediate spaced filename words when extension lookahead succeeds. Signed-off-by: Denis Co-authored-by: Cursor --- ui/desktop/src/utils/linkifyPaths.test.ts | 37 +++++++++++++++++++++ ui/desktop/src/utils/linkifyPaths.ts | 39 +++++++++++++++++++++-- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts index ea3c8132db42..6acd67c8bfa7 100644 --- a/ui/desktop/src/utils/linkifyPaths.test.ts +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -142,6 +142,26 @@ describe('path linkification', () => { ); }); + it('detects spaced filenames with hyphenated or underscored intermediate words', () => { + expect(findPaths('Saved /tmp/project release-notes draft.txt')[0][1]).toBe( + '/tmp/project release-notes draft.txt' + ); + expect(findPaths('Saved /tmp/project release_notes draft.txt')[0][1]).toBe( + '/tmp/project release_notes draft.txt' + ); + expect(findPaths('Saved /tmp/project release-notes draft.txt.')[0][1]).toBe( + '/tmp/project release-notes draft.txt' + ); + expect(findPaths('Saved /tmp/Project release-notes draft.txt')[0][1]).toBe( + '/tmp/Project release-notes draft.txt' + ); + }); + + it('still rejects prose after two-char basenames with spaced filename lookahead', () => { + expect(findPaths('Created /tmp/ui successfully.')[0][1]).toBe('/tmp/ui'); + expect(findPaths('Check /tmp/go output')[0][1]).toBe('/tmp/go'); + }); + it('detects paths with uppercase extension words after spaced segments', () => { expect(findPaths('Saved /tmp/project notes Draft.txt')[0][1]).toBe( '/tmp/project notes Draft.txt' @@ -355,6 +375,23 @@ describe('path linkification', () => { expect(findPaths('Visit example.com?file-name=/tmp/out&other=1')).toHaveLength(0); }); + it('does not linkify paths inside URL path assignment values', () => { + expect(findPaths('See https://host/download/file=/tmp/out')).toHaveLength(0); + expect(findPaths('See https://host/download/output=/tmp/out.log')).toHaveLength(0); + expect(findPaths('Visit example.com/download/file=/tmp/out')).toHaveLength(0); + expect(findPaths('Visit example.com/path/to/output=/tmp/out')).toHaveLength(0); + }); + + it('still linkifies assignment-style paths outside URL context', () => { + expect(findPaths('artifact=/tmp/out')[0][1]).toBe('/tmp/out'); + expect(findPaths('--output=/tmp/out')[0][1]).toBe('/tmp/out'); + expect(findPaths('See ?file=/tmp/out')).toHaveLength(0); + expect(findPaths('See #file=/tmp/out')).toHaveLength(0); + expect(findPaths('See ;file=/tmp/out')).toHaveLength(0); + expect(findPaths('See ?file[]=/tmp/out')).toHaveLength(0); + expect(findPaths('See ?file-name=/tmp/out')).toHaveLength(0); + }); + it('does not match URLs', () => { const matches = findPaths('Visit https://example.com/page for info'); expect(matches).toHaveLength(0); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts index 251545dbc945..e959fb700808 100644 --- a/ui/desktop/src/utils/linkifyPaths.ts +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -81,12 +81,43 @@ function isQueryParamKeyChar(char: string): boolean { return /[-a-zA-Z0-9_.$[\]%]/.test(char); } +function isUrlPathSlashBeforeAssignment(text: string, slashPos: number): boolean { + if (text[slashPos] !== '/') return false; + + let i = slashPos - 1; + while (i >= 0 && /[a-zA-Z0-9._-]/.test(text[i])) { + i--; + } + if (i >= 2 && text[i] === '/' && text[i - 1] === '/' && text[i - 2] === ':') { + let j = i - 3; + while (j >= 0 && /[a-zA-Z0-9+.-]/.test(text[j])) { + j--; + } + const scheme = text.slice(j + 1, i - 2); + if (scheme.length > 0 && /[a-zA-Z]/.test(scheme[0])) { + return true; + } + } + if (i >= 0 && text[i] === '/') { + return isUrlPathSlashBeforeAssignment(text, i); + } + const token = text.slice(i + 1, slashPos); + return ( + token.length > 0 && + /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$/.test(token) && + /\.[a-zA-Z]{2,}$/.test(token) + ); +} + function isAssignmentEqualsStart(text: string, index: number): boolean { if (text[index - 1] !== '=') return false; let i = index - 2; while (i >= 0 && isQueryParamKeyChar(text[i])) { i--; } + if (i >= 0 && text[i] === '/' && isUrlPathSlashBeforeAssignment(text, i)) { + return false; + } return !(i >= 0 && URL_PARAM_DELIMITERS.has(text[i])); } @@ -118,6 +149,10 @@ function hasFileExtension(word: string): boolean { return /\.[A-Za-z0-9]+$/.test(word); } +function isSpacedFilenameWord(word: string): boolean { + return /^[a-z][a-z0-9_-]*$/.test(word); +} + function canContinueWithNumericWord(word: string, prevToken: string, after: string | undefined): boolean { return ( word.length >= 4 && @@ -183,7 +218,7 @@ function readExtensionWordAhead(text: string, fromIndex: number): boolean { if (j === i) return false; const word = text.slice(i, j).replace(TRAILING_PUNCTUATION_RE, ''); if (hasFileExtension(word)) return true; - if (!/^[a-z][a-z0-9]*$/.test(word)) return false; + if (!isSpacedFilenameWord(word)) return false; i = j; } return false; @@ -208,7 +243,7 @@ function isSpacedFilenameContinuation( return true; } return ( - /^[a-z][a-z0-9]*$/.test(word) && + isSpacedFilenameWord(word) && /^[A-Za-z]/.test(prevToken) && readExtensionWordAhead(text, endIndex) );