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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 (``) 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, '')
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user