fix(native-chat): surface clipboard image save failures in the composer (STA-1465)

A failed clipboard-image save (SSH connection dropped, oversized image)
was swallowed by .catch(() => null), so the paste silently did nothing.
The terminal surfaces the same failure via setTerminalError; the composer
now shows it through its notice, and a menu-driven paste no longer falls
through to the text path on a save error.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-07-08 14:10:11 -07:00
co-authored by Orca
parent 8f6a4bcde7
commit 0de961c6b1
7 changed files with 278 additions and 19 deletions
@@ -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<typeof useNativeChatComposerPaste>
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<void> }> {
const container = document.createElement('div')
document.body.append(container)
let api: HookApi | null = null
root = createRoot(container)
const render = async (disabled: boolean): Promise<void> => {
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()
})
})
@@ -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 }
}
+1
View File
@@ -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…"
},
+1
View File
@@ -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…"
},
+1
View File
@@ -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…"
},
+1
View File
@@ -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…"
},
+1
View File
@@ -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…"
},