From ced900a57da2281f4bc439b802de159cd3a83e7e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:48:27 -0700 Subject: [PATCH] fix(desktop): file-path links in chat now open through the preview pane (#82140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assistant messages that link a file the agent wrote — [report](/home/user/report.md), file://…, ~/…, C:\… — rendered as dead anchors: file:// is blocked in the renderer, Streamdown's URL hardening turns file:/~/ hrefs into "[blocked]" spans, and on a remote gateway the path isn't on the viewer's disk at all. Issue #82140 proposed exposing the Desktop connection mode to skills/MCP/plugins so EXTENSIONS could emit different output per viewer; this fixes the symptom at the right layer instead — the viewer surface resolves paths at VIEW time, so extension output stays surface-agnostic and the same transcript works from every machine that opens it. - markdown-preprocess: routeFileLinksToPreview() rewrites filesystem-path links in prose to the renderer's existing hash-href doors — #preview/… (PreviewAttachment) for documents, #media:… for audio/video/image extensions. These pass URL hardening by design and resolve through normalizeOrLocalPreviewTarget / resolveMedia*Src: local connections read the file directly, remote connections fetch over the authenticated /api/fs bridge. Image syntax, fences, inline code, anchors, relative and http(s) links untouched. - markdown-text: MarkdownLink routes any filesystem href that still reaches it (bypassing preprocess) to PreviewAttachment/MediaAttachment instead of a bare dead . - media.ts: export isFileMediaPath. Live E2E (built app, CDP-driven, fixture session with links to a real gateway-side file): - BEFORE: [report.md] = dead (click: nothing), [notes](file://…) = "notes [blocked]" span, 0 preview affordances. - AFTER: both render as attachment rows; Open preview shows the file's content in the preview pane; zero blocked spans; screenshots verified. Closes #82140. Supersedes PR #82187 (connection-mode API): with view-time resolution the extension layer no longer needs to know where the viewer sits. --- .../markdown-text.filelinks.test.tsx | 62 +++++++++++++++++++ .../components/assistant-ui/markdown-text.tsx | 20 ++++++ apps/desktop/src/lib/markdown-preprocess.ts | 32 +++++++++- apps/desktop/src/lib/media.ts | 2 +- 4 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/components/assistant-ui/markdown-text.filelinks.test.tsx diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.filelinks.test.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.filelinks.test.tsx new file mode 100644 index 0000000000..ed0e9ebf04 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/markdown-text.filelinks.test.tsx @@ -0,0 +1,62 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { MarkdownTextContent } from './markdown-text' + +// Regression for #82140: a plain filesystem href in assistant markdown +// (`[report](/home/user/report.md)`) rendered as a bare dead anchor — +// file:// is blocked in the renderer, and on a remote gateway the path +// isn't on this disk at all. Such links must route through the preview +// pipeline (PreviewAttachment → normalizeOrLocalPreviewTarget), which +// resolves the path at VIEW time against the session's backend: local +// connections read the file directly, remote connections fetch it over the +// authenticated /api/fs bridge. Media-extension paths keep their inline +// player instead. +describe('MarkdownLink filesystem hrefs', () => { + afterEach(cleanup) + + it('routes an absolute file path link through the preview attachment', async () => { + render() + + // PreviewAttachment paints the filename + an Open preview button — + // that's the view-time door, not a dead . + await screen.findByText('report.md') + expect(screen.getByRole('button', { name: 'Open preview' })).toBeTruthy() + expect(document.querySelector('a[href="/home/user/report.md"]')).toBeNull() + }) + + it('routes file:// and ~/ links the same way', async () => { + render( + + ) + + await screen.findByText('notes.txt') + await screen.findByText('todo.md') + expect(screen.getAllByRole('button', { name: 'Open preview' })).toHaveLength(2) + }) + + it('renders a media player for a media-extension path link', async () => { + const { container } = render() + + await waitFor(() => expect(container.querySelector('video')).not.toBeNull()) + expect(container.querySelector('a[href="/tmp/demo.mp4"]')).toBeNull() + }) + + it('leaves anchors and relative links out of the preview pipeline', () => { + render( + + ) + + // Fragment anchors survive untouched; relative links are NOT rewritten + // (they keep Streamdown's pre-existing handling) — neither gains a + // preview affordance. + expect(screen.queryByRole('button', { name: 'Open preview' })).toBeNull() + expect(document.querySelector('a[href="#section-2"]')).not.toBeNull() + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index f9eb78084a..54e7f04569 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -22,6 +22,7 @@ import { parseMarkdownIntoBlocksCached } from '@/lib/markdown-blocks' import { preprocessMarkdown } from '@/lib/markdown-preprocess' import { downloadGatewayMediaFile, + isFileMediaPath, isInlineMediaSrc, isMarkdownDocumentPath, isRemoteGateway, @@ -282,6 +283,25 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a const target = href ? normalizeExternalUrl(href) : href if (!target || !/^https?:\/\//i.test(target)) { + // A plain filesystem href (`[report](/home/user/report.md)`, `file://…`, + // `~/notes.md`, `C:\…`) names a file on the AGENT's machine. A bare + // anchor is a dead link there — file:// is blocked in the renderer, and + // on a remote gateway the path isn't even on this disk. Route it through + // the preview pipeline instead: normalizeOrLocalPreviewTarget resolves at + // VIEW time against the session's backend (local reads the file directly; + // remote fetches it over the authenticated /api/fs bridge), so the same + // transcript works from every machine that opens it. Media extensions + // keep their richer inline player. + const fileHref = href && !href.startsWith('#') && isFileMediaPath(href) ? href : null + + if (fileHref) { + return mediaKind(fileHref) === 'file' ? ( + + ) : ( + + ) + } + return ( [^\]\n]+)\]\((??)\)/gi /** * Returns true when `body` contains a line that's exactly `marker` (modulo @@ -145,6 +153,26 @@ function autoLinkRawUrls(text: string): string { }) } +// Rewrite filesystem-path links to the renderer's hash-href door (#82140). +// A plain path/file: href names a file on the AGENT's machine: Streamdown's +// URL hardening blocks `file:`/`~/` outright, and an absolute path renders +// as a dead anchor (file:// is blocked in the renderer; on a remote gateway +// the file isn't on this disk at all). `#preview/…` / `#media:…` hrefs pass +// hardening by design and route through PreviewAttachment/MediaAttachment, +// which resolve the path at VIEW time against the session's backend — local +// reads the file directly, remote fetches it over the authenticated /api/fs +// bridge — so the same transcript works from every machine that opens it. +function routeFileLinksToPreview(text: string): string { + return text.replace(FILE_LINK_RE, (match: string, ...args: unknown[]) => { + const groups = args.at(-1) as { label: string; target: string } + const target = groups.target.replace(/^<|>$/g, '') + + const href = mediaKind(target) === 'file' ? previewMarkdownHref(target) : mediaMarkdownHref(target) + + return `[${groups.label}](${href})` + }) +} + function normalizeVisibleProse(text: string): string { return text .split(INLINE_CODE_SPLIT_RE) @@ -153,7 +181,9 @@ function normalizeVisibleProse(text: string): string { ? part : linkifySessionRefs( autoLinkRawUrls( - part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, '') + routeFileLinksToPreview( + part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, '') + ) ) ) ) diff --git a/apps/desktop/src/lib/media.ts b/apps/desktop/src/lib/media.ts index 3bc9808aa5..a7952a0e11 100644 --- a/apps/desktop/src/lib/media.ts +++ b/apps/desktop/src/lib/media.ts @@ -73,7 +73,7 @@ export function isInlineMediaSrc(path: string): boolean { return /^(?:https?|data):/i.test(path) } -function isFileMediaPath(path: string): boolean { +export function isFileMediaPath(path: string): boolean { return /^(?:file:|\/|~\/|[a-z]:[\\/]|\\\\)/i.test(path) }