fix(desktop): file-path links in chat now open through the preview pane (#82140)

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 <a>.
- media.ts: export isFileMediaPath.

Live E2E (built app, CDP-driven, fixture session with links to a real
gateway-side file):
- BEFORE: [report.md] = dead <a href="/home/…"> (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.
This commit is contained in:
Teknium
2026-08-18 13:48:27 -07:00
parent cb7dd6d2b8
commit ced900a57d
4 changed files with 114 additions and 2 deletions

View File

@@ -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(<MarkdownTextContent isRunning={false} text="Wrote it: [report](/home/user/report.md)" />)
// PreviewAttachment paints the filename + an Open preview button —
// that's the view-time door, not a dead <a>.
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(
<MarkdownTextContent
isRunning={false}
text={'See [notes](file:///srv/data/notes.txt) and [todo](~/todo.md)'}
/>
)
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(<MarkdownTextContent isRunning={false} text="[clip](/tmp/demo.mp4)" />)
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(
<MarkdownTextContent
isRunning={false}
text={'[frag](#section-2) and [rel](docs/guide.md) and [site](https://example.com)'}
/>
)
// 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()
})
})

View File

@@ -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' ? (
<PreviewAttachment source="explicit-link" target={fileHref} />
) : (
<MediaAttachment path={fileHref} />
)
}
return (
<a
className={cn('ref wrap-anywhere', className)}

View File

@@ -2,6 +2,8 @@ import { normalizeMathDelimiters } from '@assistant-ui/react-streamdown'
import { isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code'
import { clampHtmlNestingDepth } from '@/lib/markdown-html-depth'
import { mediaKind, mediaMarkdownHref } from '@/lib/media'
import { previewMarkdownHref } from '@/lib/preview-targets'
import { stripPreviewTargets } from '@/lib/preview-targets'
import { linkifySessionRefs } from '@/lib/session-refs'
@@ -27,6 +29,12 @@ const LOCAL_PREVIEW_URL_RE = /(^|\s)https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0
const LOCAL_PREVIEW_ONLY_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?\/?$/i
const URL_ONLY_LINE_RE = /^\s*https?:\/\/\S+\s*$/i
const CITATION_MARKER_RE = /(?<=[\p{L}\p{N})\].,!?:;"'”’])\[(?:\d+(?:\s*,\s*\d+)*)\](?!\()/gu
// Markdown links whose target is a filesystem path on the agent's machine:
// `[report](/home/user/report.md)`, `[notes](file:///srv/notes.txt)`,
// `[todo](~/todo.md)`, `[log](C:\logs\run.txt)`. Negative lookbehind keeps
// image syntax (`![alt](path)`) on its existing inline pipeline. The target
// char class excludes `)`/whitespace, matching how LLMs actually emit these.
const FILE_LINK_RE = /(?<!!)\[(?<label>[^\]\n]+)\]\((?<target><?(?: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, '')
)
)
)
)

View File

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