fix(desktop): open remote file rows in the in-app preview

A plain click on a composer file row in remote mode handed the backend's
file:// URL to the local browser bridge, which cannot resolve a path that
only exists on the gateway host. Route remote non-HTML file targets to the
gateway-backed in-app preview pane instead; local files, ordinary URLs, and
remote HTML (staged locally by openPreviewTargetInBrowser) keep their
existing browser path.

Supersedes #70296 and #57878.

Co-authored-by: lesterlxt <153183032+lesterlxt@users.noreply.github.com>
Co-authored-by: cj52973 <cjenkins@scacpa.org>
This commit is contained in:
Brooklyn Nicholson 2026-08-05 10:07:18 -06:00
parent 069551d19b
commit 17a5a95871
2 changed files with 148 additions and 7 deletions

View File

@ -1,11 +1,22 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $previewTabs, closeRightRail } from '@/store/preview'
import { $connection } from '@/store/session'
import { PreviewStatusRow } from './preview-row'
describe('PreviewStatusRow', () => {
beforeEach(() => {
$connection.set(null)
closeRightRail()
})
afterEach(() => {
cleanup()
$connection.set(null)
closeRightRail()
vi.restoreAllMocks()
})
it('keeps the preview tooltip label inline inside the portaled decoration', async () => {
@ -27,4 +38,118 @@ describe('PreviewStatusRow', () => {
expect(label?.classList.contains('inline-flex')).toBe(true)
expect(label?.classList.contains('flex')).toBe(false)
})
it('opens remote non-HTML file artifacts in the in-app preview instead of the local browser bridge', async () => {
const remotePath = '/home/agent/report.pdf'
const openPreviewInBrowser = vi.fn(async () => undefined)
$connection.set({ mode: 'remote' } as never)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
api: vi.fn(async () => ({ binary: true, byteSize: 42, mimeType: 'application/pdf' })),
normalizePreviewTarget: vi.fn(async () => ({
kind: 'file',
label: 'report.pdf',
path: remotePath,
previewKind: 'binary',
source: remotePath,
url: 'file:///home/agent/report.pdf'
})),
openPreviewInBrowser
}
})
render(
<PreviewStatusRow
item={{ cwd: '/home/agent', id: remotePath, label: 'report.pdf', target: remotePath }}
onDismiss={() => undefined}
/>
)
fireEvent.click(screen.getByText('report.pdf'))
await waitFor(() => {
expect($previewTabs.get()).toEqual([
expect.objectContaining({ target: expect.objectContaining({ kind: 'file', path: remotePath }) })
])
})
expect(openPreviewInBrowser).not.toHaveBeenCalled()
})
it('keeps local file artifacts on the browser bridge', async () => {
const localPath = '/Users/alice/report.pdf'
const openPreviewInBrowser = vi.fn(async () => undefined)
$connection.set({ mode: 'local' } as never)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
normalizePreviewTarget: vi.fn(async () => ({
kind: 'file',
label: 'report.pdf',
path: localPath,
previewKind: 'binary',
source: localPath,
url: 'file:///Users/alice/report.pdf'
})),
openPreviewInBrowser
}
})
render(
<PreviewStatusRow
item={{ cwd: '/Users/alice', id: localPath, label: 'report.pdf', target: localPath }}
onDismiss={() => undefined}
/>
)
fireEvent.click(screen.getByText('report.pdf'))
await waitFor(() => {
expect(openPreviewInBrowser).toHaveBeenCalledWith('file:///Users/alice/report.pdf')
})
expect($previewTabs.get()).toEqual([])
})
it('keeps remote HTML on the staged browser-open path, not the in-app pane', async () => {
const remotePath = '/home/agent/index.html'
const html = '<!doctype html><html><body>hi</body></html>'
const dataUrl = `data:text/html;base64,${btoa(html)}`
const openPreviewInBrowser = vi.fn(async () => undefined)
const saveImageBuffer = vi.fn(async () => '/tmp/staged.html')
$connection.set({ mode: 'remote' } as never)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
api: vi.fn(async () => dataUrl),
normalizePreviewTarget: vi.fn(async () => ({
kind: 'file',
label: 'index.html',
path: remotePath,
previewKind: 'html',
source: remotePath,
url: 'file:///home/agent/index.html'
})),
openPreviewInBrowser,
saveImageBuffer
}
})
render(
<PreviewStatusRow
item={{ cwd: '/home/agent', id: remotePath, label: 'index.html', target: remotePath }}
onDismiss={() => undefined}
/>
)
fireEvent.click(screen.getByText('index.html'))
await waitFor(() => {
expect(saveImageBuffer).toHaveBeenCalled()
expect(openPreviewInBrowser).toHaveBeenCalledWith('file:///tmp/staged.html')
})
expect($previewTabs.get()).toEqual([])
})
})

View File

@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
import { normalizeOrLocalPreviewTarget, openPreviewTargetInBrowser } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { PREVIEW_PANE_ID } from '@/store/layout'
@ -59,9 +60,23 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss
}
}
const openInBrowser = async () => {
const openDefaultTarget = async () => {
try {
await openPreviewTargetInBrowser(await resolveTarget())
const target = await resolveTarget()
// A file:// URL resolved in remote mode names a file on the backend
// host, not on the machine running Electron. Keep local files and
// ordinary URLs on the browser path, but route remote files through the
// in-app preview pane so its filesystem adapter reads via the gateway.
// (Remote HTML stays on openPreviewTargetInBrowser, which stages a
// sanitized local copy before opening it.)
if (target.kind === 'file' && target.previewKind !== 'html' && isDesktopFsRemoteMode()) {
openPreview(target, 'tool-result')
return
}
await openPreviewTargetInBrowser(target)
} catch (error) {
notifyError(error, t.preview.unavailable)
}
@ -77,13 +92,14 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss
size="0.8rem"
/>
}
// Plain click opens the link in the browser; ⌘/Ctrl-click opens it in the
// in-app preview pane instead. (isOpen still toggles the pane closed.)
// Plain click opens the link in the browser, except remote files which
// only the in-app gateway-backed preview can read. ⌘/Ctrl-click always
// uses the in-app preview pane. (isOpen still toggles the pane closed.)
onActivate={event => {
if (event.metaKey || event.ctrlKey) {
void togglePreview()
} else {
void openInBrowser()
void openDefaultTarget()
}
}}
trailing={