diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts index 5f3f91b04..9ecf4faa6 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index 5facd58f4..1ebd2420f 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -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 { + 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 })