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]+)\]\((?(?:file:\/\/|\/|~\/|[a-z]:[\\/])[^)\s]*>?)\)/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)
}