diff --git a/apps/desktop/src/app/chat/session-tile-actions.ts b/apps/desktop/src/app/chat/session-tile-actions.ts index 469691384598..16b47b995207 100644 --- a/apps/desktop/src/app/chat/session-tile-actions.ts +++ b/apps/desktop/src/app/chat/session-tile-actions.ts @@ -169,7 +169,12 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses } if (attachment.kind === 'image' || attachment.kind === 'file') { - const next = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId }) + const next = await uploadComposerAttachment(attachment, { + backendCwd: readState()?.cwd, + remote, + requestGateway, + sessionId + }) if (options.updateComposerAttachments ?? true) { scope.attachments.update(next) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index c1660530a035..290e631f4ee4 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -12,6 +12,7 @@ import { $notifications, clearNotifications } from '@/store/notifications' import { $busy, $connection, + $currentCwd, $currentUsage, $messages, $sessions, @@ -2128,6 +2129,7 @@ describe('usePromptActions file attachment sync', () => { afterEach(() => { cleanup() $connection.set(null) + $currentCwd.set('') vi.restoreAllMocks() }) @@ -2190,6 +2192,100 @@ describe('usePromptActions file attachment sync', () => { }) }) + + it('uploads Windows file bytes when local mode fronts a POSIX WSL/Docker backend', async () => { + $connection.set({ mode: 'local' } as never) + $currentCwd.set('/root') + const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,aGVsbG8=') + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { readFileDataUrl } + }) + + const attachment: ComposerAttachment = { + ...fileAttachment(), + path: 'C:\\Users\\alice\\Downloads\\report.txt', + refText: '@file:`C:\\Users\\alice\\Downloads\\report.txt`' + } + const calls: { method: string; params?: Record }[] = [] + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + + if (method === 'file.attach') { + return { + attached: true, + path: '/root/.hermes/desktop-attachments/report.txt', + ref_text: '@file:.hermes/desktop-attachments/report.txt', + uploaded: true + } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> + ) + + expect(await handle!.submitText('summarize', { attachments: [attachment] })).toBe(true) + expect(readFileDataUrl).toHaveBeenCalledWith('C:\\Users\\alice\\Downloads\\report.txt') + expect(calls[0]).toEqual({ + method: 'file.attach', + params: { + data_url: 'data:text/plain;base64,aGVsbG8=', + name: 'report.txt', + path: 'C:\\Users\\alice\\Downloads\\report.txt', + session_id: RUNTIME_SESSION_ID + } + }) + expect(calls[1]).toEqual({ + method: 'prompt.submit', + params: { session_id: RUNTIME_SESSION_ID, text: '@file:.hermes/desktop-attachments/report.txt\n\nsummarize' } + }) + }) + + it('uses image.attach_bytes for a Windows image when the local backend cwd is POSIX', async () => { + const readFileDataUrl = vi.fn(async () => 'data:image/jpeg;base64,aGVsbG8=') + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { readFileDataUrl } + }) + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'image.attach_bytes') { + return { attached: true, path: '/root/tmp/photo.jpg' } as never + } + + return {} as never + }) + + const uploaded = await uploadComposerAttachment( + { + id: 'image:photo.jpg', + kind: 'image', + label: 'photo.jpg', + path: 'C:\\Users\\alice\\Pictures\\photo.jpg' + }, + { + backendCwd: '/root', + remote: false, + requestGateway, + sessionId: RUNTIME_SESSION_ID + } + ) + + expect(readFileDataUrl).toHaveBeenCalledWith('C:\\Users\\alice\\Pictures\\photo.jpg') + expect(requestGateway).toHaveBeenCalledWith('image.attach_bytes', { + content_base64: 'aGVsbG8=', + filename: 'photo.jpg', + session_id: RUNTIME_SESSION_ID + }) + expect(requestGateway).not.toHaveBeenCalledWith('image.attach', expect.anything()) + expect(uploaded.path).toBe('/root/tmp/photo.jpg') + }) + it('passes a path-less @file: ref straight through (no path = nothing to upload)', async () => { // Submit-layer contract: only attachments that carry a `path` are upload // candidates. A path-less ref (an @-mention/context ref or pasted text) @@ -2237,8 +2333,20 @@ describe('usePromptActions file attachment sync', () => { expect(calls[0]?.params?.text).toContain('@file:`/Users/mahmoud/Downloads/DEVIS_signed.pdf`') }) - it('passes the path directly via file.attach in local mode (no byte upload)', async () => { + it('passes a Windows path directly for a native Windows local backend', async () => { $connection.set({ mode: 'local' } as never) + $currentCwd.set('C:\\Users\\alice\\project') + const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,c2hvdWxkLW5vdC1iZS1yZWFk') + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { readFileDataUrl } + }) + + const attachment: ComposerAttachment = { + ...fileAttachment(), + path: 'C:\\Users\\alice\\Downloads\\report.txt', + refText: '@file:`C:\\Users\\alice\\Downloads\\report.txt`' + } const calls: { method: string; params?: Record }[] = [] @@ -2257,11 +2365,12 @@ describe('usePromptActions file attachment sync', () => { (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) - const ok = await handle!.submitText('summarize', { attachments: [fileAttachment()] }) + const ok = await handle!.submitText('summarize', { attachments: [attachment] }) expect(ok).toBe(true) expect(calls[0]?.method).toBe('file.attach') - // Local mode sends no data_url — the gateway shares this disk. + expect(readFileDataUrl).not.toHaveBeenCalled() + // Native Windows local mode shares the same path namespace. expect(calls[0]?.params).not.toHaveProperty('data_url') expect(calls[1]).toEqual({ method: 'prompt.submit', diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 681ef8d4199b..6307969421bf 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -25,6 +25,7 @@ import { clearAllPrompts } from '@/store/prompts' import { $busy, $connection, + $currentCwd, $messages, setAwaitingResponse, setBusy, @@ -76,26 +77,38 @@ interface HandoffResult { error?: string } +const WINDOWS_ABSOLUTE_PATH_RE = /^(?:[A-Za-z]:[\\/]|\\\\)/ +const POSIX_ABSOLUTE_PATH_RE = /^\/(?!\/)/ + +// `mode: local` means the gateway was launched locally, not necessarily that +// Electron and the gateway share a filesystem. Windows Desktop can front a +// WSL/Docker backend whose cwd is POSIX, so a Windows host path must cross the +// boundary as bytes just like a remote attachment. +function attachmentPathNeedsUpload(path: string, backendCwd?: null | string): boolean { + return WINDOWS_ABSOLUTE_PATH_RE.test(path.trim()) && POSIX_ABSOLUTE_PATH_RE.test(backendCwd?.trim() || '') +} + /** * Stage one file/image attachment into the session workspace and return the - * attachment rewritten with the gateway-side ref. Images upload their bytes in - * remote mode (so vision works) and pass the path locally; non-image files - * upload bytes remotely and pass the path locally. Throws on failure so callers - * can surface an error. Shared by submit-time sync, the eager drop-time upload, - * and the message-edit composer drop — keep them in lockstep. + * attachment rewritten with the gateway-side ref. Attachments upload their + * bytes for remote gateways and local cross-filesystem backends; otherwise the + * gateway receives the shared local path. Throws on failure so callers can + * surface an error. Shared by submit-time sync, the eager drop-time upload, and + * the message-edit composer drop — keep them in lockstep. */ export async function uploadComposerAttachment( attachment: ComposerAttachment, - opts: { remote: boolean; requestGateway: GatewayRequest; sessionId: string } + opts: { backendCwd?: null | string; remote: boolean; requestGateway: GatewayRequest; sessionId: string } ): Promise { - const { remote, requestGateway, sessionId } = opts + const { backendCwd, remote, requestGateway, sessionId } = opts const path = attachment.path ?? '' const label = attachment.label || pathLabel(path) + const uploadBytes = remote || attachmentPathNeedsUpload(path, backendCwd) if (attachment.kind === 'image') { let result: ImageAttachResponse - if (remote) { + if (uploadBytes) { let payload: Awaited> try { @@ -138,7 +151,7 @@ export async function uploadComposerAttachment( // Non-image file. let dataUrl: string | null = null - if (remote) { + if (uploadBytes) { try { dataUrl = await readFileDataUrlForAttach(path) } catch (err) { @@ -317,7 +330,12 @@ export function usePromptActions({ } if (attachment.kind === 'image' || attachment.kind === 'file') { - const nextAttachment = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId }) + const nextAttachment = await uploadComposerAttachment(attachment, { + backendCwd: $currentCwd.get(), + remote, + requestGateway, + sessionId + }) // Update-only: never resurrect a chip the user removed mid-upload. if (updateComposerAttachments) { @@ -356,7 +374,14 @@ export function usePromptActions({ try { // Update-only: if the user removed the chip while this was uploading, // don't resurrect it — just drop the staged result on the floor. - updateComposerAttachment(await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })) + updateComposerAttachment( + await uploadComposerAttachment(attachment, { + backendCwd: $currentCwd.get(), + remote, + requestGateway, + sessionId + }) + ) } catch (err) { // Leave the chip in place so submit-time sync can retry (or the user can // remove it) and flag the card; also toast so a hard failure (unreadable diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index 52411d6bd3e9..bf8df4ea01e8 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -415,7 +415,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess try { const uploaded = await uploadComposerAttachment( { detail: path, id: attachmentId(kind, path), kind, label: pathLabel(path), path }, - { remote, requestGateway, sessionId } + { backendCwd: cwd, remote, requestGateway, sessionId } ) const ref = attachmentDisplayText(uploaded)