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)
}