From 17a5a9587119eabc4bc5d925d4e730de5ea2066c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 5 Aug 2026 10:07:18 -0600 Subject: [PATCH] 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 --- .../status-stack/preview-row.test.tsx | 129 +++++++++++++++++- .../composer/status-stack/preview-row.tsx | 26 +++- 2 files changed, 148 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/status-stack/preview-row.test.tsx b/apps/desktop/src/app/chat/composer/status-stack/preview-row.test.tsx index 3bc38ed92b060..0460291188e1f 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/preview-row.test.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/preview-row.test.tsx @@ -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( + 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( + 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 = 'hi' + 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( + undefined} + /> + ) + + fireEvent.click(screen.getByText('index.html')) + + await waitFor(() => { + expect(saveImageBuffer).toHaveBeenCalled() + expect(openPreviewInBrowser).toHaveBeenCalledWith('file:///tmp/staged.html') + }) + expect($previewTabs.get()).toEqual([]) + }) }) diff --git a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx index eb6b27ffb04c5..995b1b8cc8b06 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx @@ -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={