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