fix(desktop): read attachment previews local-first in remote mode

attachImagePath fetched its thumbnail through readDesktopFileDataUrl, which in
remote mode routes every read to the gateway fs bridge. Paperclip picks,
clipboard saves, and OS drops always produce paths on the LOCAL machine, so the
gateway read 404s — toasting "image preview failed" and dropping the thumbnail
even though the attach itself works (upload reads local bytes via the Electron
bridge). Read the local bridge first and fall back to the remote facade, which
still serves in-app drags from the remote project tree. Local mode is
unchanged (the facade already reads locally there).

Follow-up to #56572, which restored the remote paperclip picker and made this
path reachable from the picker as well.
This commit is contained in:
Brooklyn Nicholson 2026-07-02 12:34:08 -05:00 committed by brooklyn!
parent c19bfb50ad
commit fe82b3a774
2 changed files with 91 additions and 2 deletions

View file

@ -1,6 +1,14 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from './use-composer-actions'
import { $connection } from '@/store/session'
import {
attachmentPreviewDataUrl,
type DroppedFile,
extractDroppedFiles,
HERMES_PATHS_MIME,
partitionDroppedFiles
} from './use-composer-actions'
// A Finder/Explorer drop carries a native File handle; an in-app drag (project
// tree, gutter line ref) is path-only. The split decides whether a drop becomes
@ -178,3 +186,61 @@ describe('extractDroppedFiles', () => {
expect(result[0]?.isDirectory).toBe(true)
})
})
describe('attachmentPreviewDataUrl', () => {
const LOCAL_PREVIEW = 'data:image/png;base64,bG9jYWw='
const REMOTE_PREVIEW = 'data:image/png;base64,cmVtb3Rl'
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
$connection.set(null)
})
it('reads a local path via the local bridge even in remote mode (paperclip/paste/OS drop)', async () => {
const readFileDataUrl = vi.fn(async () => LOCAL_PREVIEW)
const api = vi.fn()
vi.stubGlobal('window', { hermesDesktop: { api, readFileDataUrl } })
$connection.set({ mode: 'remote' } as never)
await expect(attachmentPreviewDataUrl('/Users/me/Pictures/pic.png')).resolves.toBe(LOCAL_PREVIEW)
expect(readFileDataUrl).toHaveBeenCalledWith('/Users/me/Pictures/pic.png')
expect(api).not.toHaveBeenCalled()
})
it('falls back to the remote fs bridge when the path is not on this machine (project-tree drag)', async () => {
const readFileDataUrl = vi.fn(async () => {
throw new Error('ENOENT')
})
const api = vi.fn(async ({ path }: { path: string }) => {
if (path.startsWith('/api/fs/read-data-url?')) {
return { dataUrl: REMOTE_PREVIEW }
}
throw new Error(`unexpected path ${path}`)
})
vi.stubGlobal('window', { hermesDesktop: { api, readFileDataUrl } })
$connection.set({ mode: 'remote' } as never)
await expect(attachmentPreviewDataUrl('/home/gateway/shot.png')).resolves.toBe(REMOTE_PREVIEW)
expect(api).toHaveBeenCalledWith({
path: '/api/fs/read-data-url?path=%2Fhome%2Fgateway%2Fshot.png'
})
})
it('falls back when the local bridge returns an empty read', async () => {
const readFileDataUrl = vi.fn(async () => '')
const api = vi.fn(async () => ({ dataUrl: REMOTE_PREVIEW }))
vi.stubGlobal('window', { hermesDesktop: { api, readFileDataUrl } })
$connection.set({ mode: 'remote' } as never)
await expect(attachmentPreviewDataUrl('/home/gateway/shot.png')).resolves.toBe(REMOTE_PREVIEW)
})
})

View file

@ -39,6 +39,29 @@ export function isImagePath(filePath: string): boolean {
return IMAGE_EXTENSION_PATTERN.test(filePath)
}
/**
* Read an attachment's thumbnail preview, local disk first. Paperclip picks,
* clipboard saves, and OS drops always hand us paths on THIS machine the
* remote-routed fs facade would 404 them against the gateway and toast a bogus
* "preview failed" even though the attach itself works (upload reads local
* bytes too). In-app drags from the remote project tree are the opposite case:
* the local read fails there, so fall back to the facade (remote fs bridge).
* In local mode the facade IS the local bridge, so this stays a single read.
*/
export async function attachmentPreviewDataUrl(filePath: string): Promise<string> {
try {
const local = await window.hermesDesktop?.readFileDataUrl?.(filePath)
if (local) {
return local
}
} catch {
// Not on this machine (or unreadable locally) — try the gateway.
}
return readDesktopFileDataUrl(filePath)
}
export interface DroppedFile {
/** Browser-native File handle. Absent for in-app drags (e.g. project tree). */
file?: File
@ -367,7 +390,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
attachToMain(baseAttachment)
try {
const previewUrl = await readDesktopFileDataUrl(filePath)
const previewUrl = await attachmentPreviewDataUrl(filePath)
if (previewUrl) {
addComposerAttachment({ ...baseAttachment, previewUrl })