mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 16:02:42 +00:00
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:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
},
|
||||
|
||||
@@ -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…"
|
||||
},
|
||||
|
||||
@@ -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…"
|
||||
},
|
||||
|
||||
@@ -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…"
|
||||
},
|
||||
|
||||
@@ -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…"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user