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 76ab53ef95..474cda2f2b 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,5 +1,7 @@ +import { act, renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ComposerAttachment } from '@/store/composer' import { $connection } from '@/store/session' import { @@ -7,7 +9,8 @@ import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME, - partitionDroppedFiles + partitionDroppedFiles, + useComposerActions } from './use-composer-actions' // A Finder/Explorer drop carries a native File handle; an in-app drag (project @@ -244,3 +247,74 @@ describe('attachmentPreviewDataUrl', () => { await expect(attachmentPreviewDataUrl('/home/gateway/shot.png')).resolves.toBe(REMOTE_PREVIEW) }) }) + +describe('useComposerActions native image drops', () => { + afterEach(() => { + Reflect.deleteProperty(window, 'hermesDesktop') + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('copies dropped screenshot bytes before trusting a transient macOS path', async () => { + const transientPath = + '/var/folders/x7/example/T/TemporaryItems/NSIRD_screencaptureui_4roSuW/Screen Shot 2026-08-11.png' + + const durablePath = '/Users/test/Library/Application Support/Hermes/composer-images/composer_saved.png' + const previewUrl = 'data:image/png;base64,c2NyZWVuc2hvdA==' + + const screenshot = new File([new Uint8Array([1, 2, 3])], 'Screen Shot 2026-08-11.png', { + type: 'image/png' + }) + + const saveImageBuffer = vi.fn(async () => durablePath) + + const readFileDataUrl = vi.fn(async (path: string) => { + if (path === transientPath) { + throw new Error('temporary screenshot path disappeared') + } + + return previewUrl + }) + + const add = vi.fn<(attachment: ComposerAttachment) => void>() + + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { + readFileDataUrl, + saveImageBuffer + } + }) + + const { result } = renderHook(() => + useComposerActions({ + activeSessionId: null, + currentCwd: '/Users/test/project', + requestGateway: vi.fn(), + scope: { + add, + remove: vi.fn(() => null), + target: 'test-composer' + } + }) + ) + + let attached = false + + await act(async () => { + attached = await result.current.attachDroppedItems([{ file: screenshot, path: transientPath }]) + }) + + expect(attached).toBe(true) + expect(saveImageBuffer).toHaveBeenCalledOnce() + expect(readFileDataUrl).toHaveBeenCalledWith(durablePath) + expect(readFileDataUrl).not.toHaveBeenCalledWith(transientPath) + expect(add).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'image', + path: durablePath, + previewUrl + }) + ) + }) +}) 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 db3c16e8fb..0ff553d033 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -644,7 +644,14 @@ export function useComposerActions({ const isImage = file.type.startsWith('image/') || isImagePath(file.name) || (filePath && isImagePath(filePath)) if (isImage) { - if ((filePath && (await attachImagePath(filePath))) || (await attachImageBlob(file))) { + // Finder may expose a dropped screenshot through a short-lived + // TemporaryItems/NSIRD_screencaptureui path even when the visible + // file has already landed on Desktop. Reading that path for the + // preview can succeed, then image.attach fails after macOS removes + // it before submit. Persist the File bytes into Desktop's durable + // composer-image cache first; keep the native path as a compatibility + // fallback for older shells that cannot save the buffer. + if ((await attachImageBlob(file)) || (filePath && (await attachImagePath(filePath)))) { attached = true continue