diff --git a/apps/desktop/src/components/assistant-ui/markdown-file-links.test.tsx b/apps/desktop/src/components/assistant-ui/markdown-file-links.test.tsx
new file mode 100644
index 0000000000000..7485de1616ba2
--- /dev/null
+++ b/apps/desktop/src/components/assistant-ui/markdown-file-links.test.tsx
@@ -0,0 +1,37 @@
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { $connection } from '@/store/session'
+
+import { MarkdownTextContent } from './markdown-text'
+
+const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
+const initialHermesDesktop = desktopWindow.hermesDesktop
+
+afterEach(() => {
+ cleanup()
+ $connection.set(null)
+ vi.restoreAllMocks()
+
+ if (initialHermesDesktop) {
+ desktopWindow.hermesDesktop = initialHermesDesktop
+ } else {
+ delete desktopWindow.hermesDesktop
+ }
+})
+
+describe('MarkdownTextContent file links', () => {
+ it('opens file links through the remote download endpoint', () => {
+ const openExternal = vi.fn().mockResolvedValue(undefined)
+ desktopWindow.hermesDesktop = { openExternal } as unknown as Window['hermesDesktop']
+ $connection.set({ baseUrl: 'https://gw', mode: 'remote', token: 's e/cret' } as never)
+
+ render()
+
+ fireEvent.click(screen.getByRole('link', { name: 'Open a b.pdf' }))
+
+ expect(openExternal).toHaveBeenCalledWith(
+ 'https://gw/api/files/download?path=%2Ftmp%2Fa%20b.pdf&token=s%20e%2Fcret'
+ )
+ })
+})
diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx
index beabcbf8cc272..8f2ce4e3bb2a9 100644
--- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx
+++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx
@@ -213,6 +213,21 @@ function MediaAttachment({ path }: { path: string }) {
)
}
+ if (kind === 'file') {
+ return (
+ {
+ event.preventDefault()
+ openExternalLink(mediaExternalUrl(path))
+ }}
+ >
+ Open {name}
+
+ )
+ }
+
return (
"'`*.,;:!?]` class.
const RAW_URL_RE = /https?:\/\/[^\s<>"'`*]+[^\s<>"'`*.,;:!?]/g
+const LOCAL_FILE_LINK_RE = /(?"'`]*/gi
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
@@ -138,6 +139,43 @@ function autoLinkRawUrls(text: string): string {
})
}
+function filePathFromHref(href: string): string {
+ if (/^file:/i.test(href)) {
+ try {
+ return decodeURIComponent(new URL(href).pathname)
+ } catch {
+ return href.replace(/^file:\/\//i, '')
+ }
+ }
+
+ try {
+ return decodeURIComponent(href)
+ } catch {
+ return href
+ }
+}
+
+function isAbsoluteFileHref(href: string): boolean {
+ if (/^file:/i.test(href)) {
+ return true
+ }
+
+ const clean = href.split(/[?#]/, 1)[0] || ''
+ const name = clean.split('/').filter(Boolean).pop() || ''
+
+ return /^\.[A-Za-z0-9]{1,12}$/.test(name.slice(name.lastIndexOf('.')))
+}
+
+function rewriteLocalFileLinks(text: string): string {
+ return text.replace(LOCAL_FILE_LINK_RE, (match: string, label: string, href: string) => {
+ if (!isAbsoluteFileHref(href)) {
+ return match
+ }
+
+ return `[${label}](#media:${encodeURIComponent(filePathFromHref(href))})`
+ })
+}
+
function normalizeVisibleProse(text: string): string {
return text
.split(INLINE_CODE_SPLIT_RE)
@@ -145,7 +183,9 @@ function normalizeVisibleProse(text: string): string {
part.startsWith('`')
? part
: autoLinkRawUrls(
- part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, '')
+ rewriteLocalFileLinks(
+ part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, '')
+ )
)
)
.join('')