diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx new file mode 100644 index 00000000000..144385e76e7 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx @@ -0,0 +1,220 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { createElement } from 'react' +import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' + +const mocks = vi.hoisted(() => ({ + saveClipboardImageAsTempFile: vi.fn(), + readClipboardText: vi.fn() +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('./native-chat-composer-target', () => ({ + NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES: 1024 +})) + +vi.mock('./native-chat-attachment-upload', () => ({ + nativeChatWorktreeNotReadyNotice: () => 'Worktree not ready — try again in a moment.' +})) + +vi.stubGlobal('window', { + api: { + ui: { + saveClipboardImageAsTempFile: mocks.saveClipboardImageAsTempFile, + readClipboardText: mocks.readClipboardText + } + } +}) + +import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' + +type HookApi = ReturnType + +function Probe({ + disabled, + resolveAttachmentOwner, + attachResolvedPaths, + insertTypedText, + setNotice, + onReady +}: { + disabled: boolean + resolveAttachmentOwner: () => NativeChatAttachmentOwner + attachResolvedPaths: (paths: string[]) => void + insertTypedText: (text: string) => boolean + setNotice: (notice: string | null) => void + onReady: (api: HookApi) => void +}): null { + onReady( + useNativeChatComposerPaste({ + agent: 'claude', + disabled, + caret: 0, + resolveAttachmentOwner, + attachResolvedPaths, + insertTypedText, + setCaret: () => {}, + setNotice + }) + ) + return null +} + +let root: Root | null = null + +async function renderProbe(args: { + disabled?: boolean + resolveAttachmentOwner: () => NativeChatAttachmentOwner + attachResolvedPaths?: (paths: string[]) => void + insertTypedText?: (text: string) => boolean + setNotice?: (notice: string | null) => void +}): Promise<{ latest: () => HookApi; setDisabled: (disabled: boolean) => Promise }> { + const container = document.createElement('div') + document.body.append(container) + let api: HookApi | null = null + root = createRoot(container) + const render = async (disabled: boolean): Promise => { + await act(async () => { + root?.render( + createElement(Probe, { + disabled, + resolveAttachmentOwner: args.resolveAttachmentOwner, + attachResolvedPaths: args.attachResolvedPaths ?? (() => {}), + insertTypedText: args.insertTypedText ?? (() => true), + setNotice: args.setNotice ?? (() => {}), + onReady: (next) => { + api = next + } + }) + ) + }) + } + await render(args.disabled ?? false) + return { + latest: () => { + if (!api) { + throw new Error('Probe did not render') + } + return api + }, + setDisabled: render + } +} + +function imagePasteEvent(): { + clipboardData: DataTransfer + preventDefault: () => void + defaultPrevented: boolean +} { + return { + clipboardData: { items: [{ type: 'image/png' }] } as unknown as DataTransfer, + preventDefault: vi.fn(), + defaultPrevented: false + } +} + +const sshOwner: NativeChatAttachmentOwner = { + kind: 'ssh', + connectionId: 'conn-1', + worktreePath: '/remote/wt' +} + +afterEach(() => { + root?.unmount() + root = null + vi.clearAllMocks() +}) + +describe('useNativeChatComposerPaste', () => { + it('surfaces a failed SSH image save through the composer notice', async () => { + mocks.saveClipboardImageAsTempFile.mockRejectedValue( + new Error('Remote connection dropped. Click Reconnect on the SSH target before retrying.') + ) + const attachResolvedPaths = vi.fn() + const setNotice = vi.fn() + const probe = await renderProbe({ + resolveAttachmentOwner: () => sshOwner, + attachResolvedPaths, + setNotice + }) + await act(async () => { + probe.latest().handlePaste(imagePasteEvent()) + }) + expect(setNotice).toHaveBeenCalledWith( + 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' + ) + expect(attachResolvedPaths).not.toHaveBeenCalled() + }) + + it('saves on the SSH host and attaches the returned remote path', async () => { + mocks.saveClipboardImageAsTempFile.mockResolvedValue('/remote/tmp/orca-paste-1.png') + const attachResolvedPaths = vi.fn() + const probe = await renderProbe({ + resolveAttachmentOwner: () => sshOwner, + attachResolvedPaths + }) + await act(async () => { + probe.latest().handlePaste(imagePasteEvent()) + }) + expect(mocks.saveClipboardImageAsTempFile).toHaveBeenCalledWith({ connectionId: 'conn-1' }) + expect(attachResolvedPaths).toHaveBeenCalledWith(['/remote/tmp/orca-paste-1.png']) + }) + + it('stops pasteFromClipboard on a failed save instead of falling through to text', async () => { + mocks.saveClipboardImageAsTempFile.mockRejectedValue(new Error('sftp down')) + const insertTypedText = vi.fn() + const setNotice = vi.fn() + const probe = await renderProbe({ + resolveAttachmentOwner: () => sshOwner, + insertTypedText, + setNotice + }) + await act(async () => { + probe.latest().pasteFromClipboard() + }) + expect(setNotice).toHaveBeenCalledWith('sftp down') + expect(mocks.readClipboardText).not.toHaveBeenCalled() + expect(insertTypedText).not.toHaveBeenCalled() + }) + + it('still falls through to text when the clipboard holds no image', async () => { + mocks.saveClipboardImageAsTempFile.mockResolvedValue(null) + mocks.readClipboardText.mockResolvedValue('hello') + const insertTypedText = vi.fn() + const probe = await renderProbe({ + resolveAttachmentOwner: () => ({ kind: 'local' }), + insertTypedText + }) + await act(async () => { + probe.latest().pasteFromClipboard() + }) + expect(insertTypedText).toHaveBeenCalledWith('hello') + }) + + it('suppresses the failure notice when the composer became disabled mid-save', async () => { + let rejectSave: (error: Error) => void = () => {} + mocks.saveClipboardImageAsTempFile.mockReturnValue( + new Promise((_resolve, reject) => { + rejectSave = reject + }) + ) + const setNotice = vi.fn() + const probe = await renderProbe({ + resolveAttachmentOwner: () => sshOwner, + setNotice + }) + await act(async () => { + probe.latest().handlePaste(imagePasteEvent()) + }) + await probe.setDisabled(true) + await act(async () => { + rejectSave(new Error('sftp down')) + }) + expect(setNotice).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts index b89909bcedc..dbb6a06ed03 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts @@ -1,5 +1,6 @@ import { useCallback, useRef } from 'react' import { translate } from '@/i18n/i18n' +import { extractIpcErrorMessage } from '@/lib/ipc-error' import type { AgentType } from '../../../../shared/agent-status-types' import { resolveImagePaste } from './native-chat-image-paste' import { NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES } from './native-chat-composer-target' @@ -66,6 +67,36 @@ export function useNativeChatComposerPaste({ const disabledRef = useRef(disabled) disabledRef.current = disabled + // Distinguishes 'empty' (no image on the clipboard — text may fall through) + // from 'failed' (save errored — the flow must stop and say why). + const saveClipboardImageForOwner = useCallback( + async ( + owner: NativeChatAttachmentOwner + ): Promise<{ status: 'saved'; tempPath: string } | { status: 'empty' | 'failed' }> => { + try { + // SSH panes save the image on the remote host (SFTP) so the attached + // path is readable by the remote agent, matching terminal image paste. + const tempPath = await window.api.ui.saveClipboardImageAsTempFile( + owner.kind === 'ssh' ? { connectionId: owner.connectionId } : undefined + ) + return tempPath ? { status: 'saved', tempPath } : { status: 'empty' } + } catch (error) { + // A failed save must be visible: over SSH it fails whenever the + // connection drops, and a silent no-op reads as a broken paste. + if (!disabledRef.current) { + setNotice( + extractIpcErrorMessage( + error, + translate('components.native-chat.composer.imagePasteFailed', 'Image paste failed.') + ) + ) + } + return { status: 'failed' } + } + }, + [setNotice] + ) + const attachClipboardImageTempFile = useCallback( (tempPath: string) => { const result = resolveImagePaste(agent, tempPath) @@ -108,21 +139,22 @@ export function useNativeChatComposerPaste({ // state can move (further typing/selection) while the await is in flight. const caretAtPaste = caret void (async () => { - // SSH panes save the image on the remote host (SFTP) so the attached - // path is readable by the remote agent, matching terminal image paste. - const tempPath = await window.api.ui - .saveClipboardImageAsTempFile( - owner.kind === 'ssh' ? { connectionId: owner.connectionId } : undefined - ) - .catch(() => null) - if (!tempPath || disabledRef.current) { + const saved = await saveClipboardImageForOwner(owner) + if (saved.status !== 'saved' || disabledRef.current) { return } - attachClipboardImageTempFile(tempPath) + attachClipboardImageTempFile(saved.tempPath) setCaret(caretAtPaste) })() }, - [attachClipboardImageTempFile, caret, resolveAttachmentOwner, setCaret, setNotice] + [ + attachClipboardImageTempFile, + caret, + resolveAttachmentOwner, + saveClipboardImageForOwner, + setCaret, + setNotice + ] ) const pasteFromClipboard = useCallback(() => { @@ -132,20 +164,16 @@ export function useNativeChatComposerPaste({ // way to LEARN whether the clipboard holds an image. An image then gets // the not-ready notice (never a local-path attach for a possibly-remote // worktree); plain text falls through unaffected. - const tempPath = await window.api.ui - .saveClipboardImageAsTempFile( - owner.kind === 'ssh' ? { connectionId: owner.connectionId } : undefined - ) - .catch(() => null) - if (disabledRef.current) { + const saved = await saveClipboardImageForOwner(owner) + if (disabledRef.current || saved.status === 'failed') { return } - if (tempPath) { + if (saved.status === 'saved') { if (owner.kind === 'not-ready') { setNotice(nativeChatWorktreeNotReadyNotice()) return } - attachClipboardImageTempFile(tempPath) + attachClipboardImageTempFile(saved.tempPath) return } const text = await window.api.ui @@ -158,7 +186,13 @@ export function useNativeChatComposerPaste({ insertTypedText(text) } })() - }, [attachClipboardImageTempFile, insertTypedText, resolveAttachmentOwner, setNotice]) + }, [ + attachClipboardImageTempFile, + insertTypedText, + resolveAttachmentOwner, + saveClipboardImageForOwner, + setNotice + ]) return { handlePaste, pasteFromClipboard } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 76764b2aa6f..79bb974f1a9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12423,6 +12423,7 @@ "localAttachmentUnsupported": "Local attachments are not available for remote sessions.", "removeAttachment": "Remove attachment", "pastedImageLabel": "Pasted image", + "imagePasteFailed": "Image paste failed.", "worktreeNotReady": "Worktree not ready — try again in a moment.", "uploadingAttachments": "Uploading {{value0}} file{{value1}} to remote…" }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 58ea5f6a80a..d2978db89c2 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -12423,6 +12423,7 @@ "localAttachmentUnsupported": "Los archivos adjuntos locales no están disponibles para sesiones remotas.", "removeAttachment": "Quitar archivo adjunto", "pastedImageLabel": "Imagen pegada", + "imagePasteFailed": "Image paste failed.", "worktreeNotReady": "Worktree not ready — try again in a moment.", "uploadingAttachments": "Uploading {{value0}} file{{value1}} to remote…" }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 53efffc8cec..39085e01bc2 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -12423,6 +12423,7 @@ "localAttachmentUnsupported": "ローカル添付ファイルはリモート セッションでは使用できません。", "removeAttachment": "添付ファイルを削除する", "pastedImageLabel": "貼り付けた画像", + "imagePasteFailed": "Image paste failed.", "worktreeNotReady": "Worktree not ready — try again in a moment.", "uploadingAttachments": "Uploading {{value0}} file{{value1}} to remote…" }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 2912cf1e1db..98ba88a10dd 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -12423,6 +12423,7 @@ "localAttachmentUnsupported": "원격 세션에는 로컬 첨부 파일을 사용할 수 없습니다.", "removeAttachment": "첨부파일 삭제", "pastedImageLabel": "붙여넣은 이미지", + "imagePasteFailed": "Image paste failed.", "worktreeNotReady": "Worktree not ready — try again in a moment.", "uploadingAttachments": "Uploading {{value0}} file{{value1}} to remote…" }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ae2c8aa9c12..6e3296b14f3 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -12423,6 +12423,7 @@ "localAttachmentUnsupported": "远程会话不支持本地附件。", "removeAttachment": "移除附件", "pastedImageLabel": "粘贴的图片", + "imagePasteFailed": "Image paste failed.", "worktreeNotReady": "Worktree not ready — try again in a moment.", "uploadingAttachments": "Uploading {{value0}} file{{value1}} to remote…" },