Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(<MarkdownTextContent isRunning={false} text="[Report](file:///tmp/a%20b.pdf)" />)

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'
)
})
})
15 changes: 15 additions & 0 deletions apps/desktop/src/components/assistant-ui/markdown-text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,21 @@ function MediaAttachment({ path }: { path: string }) {
)
}

if (kind === 'file') {
return (
<a
className="font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere"
href="#"
onClick={event => {
event.preventDefault()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bypasses current main's useOpenMediaFile() bridge. mediaExternalUrl() falls back to file:// when a remote connection lacks a query token, so remote OAuth sessions again target the client's filesystem. Keep the current authenticated downloadGatewayMediaFile() path here.

openExternalLink(mediaExternalUrl(path))
}}
>
Open {name}
</a>
)
}

return (
<a
className="font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere"
Expand Down
42 changes: 41 additions & 1 deletion apps/desktop/src/lib/markdown-preprocess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g
// and keeps the emphasis run intact. Other trailing punctuation is still peeled
// off by the final `[^\s<>"'`*.,;:!?]` class.
const RAW_URL_RE = /https?:\/\/[^\s<>"'`*]+[^\s<>"'`*.,;:!?]/g
const LOCAL_FILE_LINK_RE = /(?<!!)\[([^\]\n]+)\]\((file:\/\/[^)\s]+|\/[^)\s]+)\)/g
const LOCAL_PREVIEW_URL_RE = /(^|\s)https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?\/?[^\s<>"'`]*/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
Expand Down Expand Up @@ -138,14 +139,53 @@ 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] || ''

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rejects valid extensionless absolute files such as [README](/tmp/README). The matcher already restricts this path to slash-prefixed hrefs, so accept that form directly and add a regression test.

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)
.map(part =>
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('')
Expand Down