diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index b255f787e..d85a3a59b 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -41,6 +41,7 @@ import { import { useNativeChatSkills } from './use-native-chat-skills' import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' +import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' import { dispatchDictationControl } from '../dictation/dictation-control-events' import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' @@ -167,7 +168,7 @@ export const NativeChatComposer = forwardRef { void (async () => { @@ -244,9 +253,9 @@ export const NativeChatComposer = forwardRef { textareaRef.current?.focus() diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts new file mode 100644 index 000000000..71528a948 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import type { TerminalTab } from '../../../../shared/types' + +const mocks = vi.hoisted(() => ({ + toastLoading: vi.fn(() => 'toast-1'), + toastDismiss: vi.fn(), + toastError: vi.fn(), + toastMessage: vi.fn(), + resolveDroppedPathsForAgent: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: { + loading: mocks.toastLoading, + dismiss: mocks.toastDismiss, + error: mocks.toastError, + message: mocks.toastMessage + } +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +import { + resolveNativeChatAttachmentOwner, + uploadNativeChatAttachmentPaths +} from './native-chat-attachment-upload' + +function terminalTab(overrides: Partial = {}): TerminalTab { + return { + id: 'tab-1', + ptyId: null, + worktreeId: 'wt-1', + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0, + ...overrides + } +} + +function state(overrides: Partial = {}): AppState { + return { + folderWorkspaces: [], + getKnownWorktreeById: (worktreeId: string) => + worktreeId === 'wt-1' ? ({ id: 'wt-1', path: '/repo/worktree' } as never) : undefined, + projectGroups: [], + repos: [{ id: 'repo', connectionId: null }], + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { + 'wt-1': [terminalTab()] + }, + worktreesByRepo: { + repo: [{ id: 'wt-1', repoId: 'repo', path: '/repo/worktree' } as never] + }, + ...overrides + } as AppState +} + +describe('resolveNativeChatAttachmentOwner', () => { + it('resolves a local repo worktree to local', () => { + expect(resolveNativeChatAttachmentOwner(state(), 'tab-1')).toEqual({ kind: 'local' }) + }) + + it('resolves an SSH repo worktree to ssh with the worktree path', () => { + expect( + resolveNativeChatAttachmentOwner( + state({ repos: [{ id: 'repo', connectionId: 'conn-1' }] as never }), + 'tab-1' + ) + ).toEqual({ kind: 'ssh', connectionId: 'conn-1', worktreePath: '/repo/worktree' }) + }) + + it('resolves a runtime-owned repo to runtime', () => { + expect( + resolveNativeChatAttachmentOwner( + state({ + repos: [{ id: 'repo', connectionId: null, executionHostId: 'runtime:env-1' }] as never + }), + 'tab-1' + ) + ).toEqual({ kind: 'runtime' }) + }) + + it('routes unowned repos to the focused runtime host, matching terminal drops', () => { + expect( + resolveNativeChatAttachmentOwner( + state({ settings: { activeRuntimeEnvironmentId: 'env-9' } as AppState['settings'] }), + 'tab-1' + ) + ).toEqual({ kind: 'runtime' }) + }) + + it('reports not-ready when the tab has no worktree owner', () => { + expect(resolveNativeChatAttachmentOwner(state({ tabsByWorktree: {} }), 'tab-1')).toEqual({ + kind: 'not-ready' + }) + }) + + it('reports not-ready when the backing repo has not hydrated', () => { + expect(resolveNativeChatAttachmentOwner(state({ repos: [] }), 'tab-1')).toEqual({ + kind: 'not-ready' + }) + }) + + it('reports not-ready when an SSH worktree has no known path yet', () => { + expect( + resolveNativeChatAttachmentOwner( + state({ + repos: [{ id: 'repo', connectionId: 'conn-1' }] as never, + getKnownWorktreeById: () => undefined, + worktreesByRepo: { repo: [{ id: 'wt-1', repoId: 'repo' } as never] }, + tabsByWorktree: { 'wt-1': [terminalTab()] } + }), + 'tab-1' + ) + ).toEqual({ kind: 'not-ready' }) + }) +}) + +describe('uploadNativeChatAttachmentPaths', () => { + const owner = { connectionId: 'conn-1', worktreePath: '/remote/worktree' } + + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('window', { + api: { fs: { resolveDroppedPathsForAgent: mocks.resolveDroppedPathsForAgent } } + }) + }) + + it('uploads through the terminal drop resolver and returns remote paths', async () => { + mocks.resolveDroppedPathsForAgent.mockResolvedValue({ + resolvedPaths: ['/remote/worktree/.orca/drops/a.txt'], + skipped: [], + failed: [] + }) + await expect(uploadNativeChatAttachmentPaths(['/local/a.txt'], owner)).resolves.toEqual([ + '/remote/worktree/.orca/drops/a.txt' + ]) + expect(mocks.resolveDroppedPathsForAgent).toHaveBeenCalledWith({ + paths: ['/local/a.txt'], + worktreePath: '/remote/worktree', + connectionId: 'conn-1' + }) + expect(mocks.toastLoading).toHaveBeenCalledTimes(1) + expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-1') + }) + + it('surfaces per-file skips and failures through the shared drop toasts', async () => { + mocks.resolveDroppedPathsForAgent.mockResolvedValue({ + resolvedPaths: [], + skipped: [{ sourcePath: '/local/link', reason: 'symlink' }], + failed: [{ sourcePath: '/local/b.txt', reason: 'boom' }] + }) + await expect( + uploadNativeChatAttachmentPaths(['/local/link', '/local/b.txt'], owner) + ).resolves.toEqual([]) + expect(mocks.toastMessage).toHaveBeenCalledTimes(1) + expect(mocks.toastError).toHaveBeenCalledTimes(1) + }) + + it('returns null and reports when the upload IPC fails', async () => { + mocks.resolveDroppedPathsForAgent.mockRejectedValue(new Error('sftp down')) + await expect(uploadNativeChatAttachmentPaths(['/local/a.txt'], owner)).resolves.toBeNull() + expect(mocks.toastError).toHaveBeenCalledTimes(1) + expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-1') + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts new file mode 100644 index 000000000..2bac1a423 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -0,0 +1,105 @@ +// SSH-aware resolution for composer attachments (STA-1465). The composer's +// attach surfaces (file drop, file picker, image paste) receive client-local +// paths, but an SSH worktree's agent runs on the remote host — local paths must +// be uploaded first, exactly like terminal drops (docs/terminal-drop-ssh.md). + +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { extractIpcErrorMessage } from '@/lib/ipc-error' +import { getConnectionIdFromState } from '@/lib/connection-context' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import type { AppState } from '@/store/types' +import { reportTerminalDropUploadSkipsAndFailures } from '../terminal-pane/terminal-drop-upload-report' +import { + findTerminalTabWorktreeId, + resolveNativeChatFileLinkContext +} from './native-chat-file-link' + +export type NativeChatAttachmentOwner = + | { kind: 'local' } + | { kind: 'ssh'; connectionId: string; worktreePath: string } + /** Runtime-owned (`remote:`) panes keep the composer's existing + * local-attachment block; runtime upload support is a separate seam. */ + | { kind: 'runtime' } + /** Store not hydrated / worktree unknown. Callers must not attach local + * paths in this window — the worktree may turn out to be remote, and the + * agent would silently receive paths it cannot read (see #6648). */ + | { kind: 'not-ready' } + +type NativeChatAttachmentOwnerState = Pick< + AppState, + | 'folderWorkspaces' + | 'getKnownWorktreeById' + | 'projectGroups' + | 'repos' + | 'settings' + | 'tabsByWorktree' + | 'worktreesByRepo' +> + +/** Resolve who owns the composer's backing worktree at attach time. Mirrors the + * terminal drop resolver's order: runtime owner first, then SSH vs local. */ +export function resolveNativeChatAttachmentOwner( + state: NativeChatAttachmentOwnerState, + terminalTabId: string +): NativeChatAttachmentOwner { + const worktreeId = findTerminalTabWorktreeId(state.tabsByWorktree, terminalTabId) + if (!worktreeId) { + return { kind: 'not-ready' } + } + if (getRuntimeEnvironmentIdForWorktree(state, worktreeId)) { + return { kind: 'runtime' } + } + const connectionId = getConnectionIdFromState(state, worktreeId) + if (connectionId === undefined) { + return { kind: 'not-ready' } + } + if (connectionId === null) { + return { kind: 'local' } + } + const worktreePath = resolveNativeChatFileLinkContext(state, terminalTabId)?.worktreePath + if (!worktreePath) { + return { kind: 'not-ready' } + } + return { kind: 'ssh', connectionId, worktreePath } +} + +export function nativeChatWorktreeNotReadyNotice(): string { + return translate( + 'components.native-chat.composer.worktreeNotReady', + 'Worktree not ready — try again in a moment.' + ) +} + +/** + * Upload client-local paths into `${worktreePath}/.orca/drops` on the SSH + * remote and return the remote paths the agent can read (input order + * preserved). Returns null when the upload IPC itself failed; per-file + * skips/failures surface through the shared drop toasts. + */ +export async function uploadNativeChatAttachmentPaths( + paths: string[], + owner: { connectionId: string; worktreePath: string } +): Promise { + const pending = toast.loading( + translate( + 'components.native-chat.composer.uploadingAttachments', + 'Uploading {{value0}} file{{value1}} to remote…', + { value0: paths.length, value1: paths.length === 1 ? '' : 's' } + ) + ) + try { + const { resolvedPaths, skipped, failed } = await window.api.fs.resolveDroppedPathsForAgent({ + paths, + worktreePath: owner.worktreePath, + connectionId: owner.connectionId + }) + reportTerminalDropUploadSkipsAndFailures(skipped, failed) + return resolvedPaths + } catch (err) { + toast.error(extractIpcErrorMessage(err, 'Failed to upload files.')) + return null + } finally { + toast.dismiss(pending) + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx index 6c69e8d92..eb92f1746 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx @@ -92,7 +92,7 @@ describe('useNativeChatComposerAttachments', () => { const first = await renderProbe('pty-1') await act(async () => { - first.latest().attachLocalPaths(['/tmp/orca-native-chat-attach-test.png']) + first.latest().attachResolvedPaths(['/tmp/orca-native-chat-attach-test.png']) }) // Images are NOT sent to the TUI on attach — they ride along on submit, so @@ -116,7 +116,7 @@ describe('useNativeChatComposerAttachments', () => { it('removes an attached image chip cleanly', async () => { const probe = await renderProbe('pty-1') await act(async () => { - probe.latest().attachLocalPaths(['/tmp/orca-native-chat-remove-test.png']) + probe.latest().attachResolvedPaths(['/tmp/orca-native-chat-remove-test.png']) }) const id = probe.latest().imageAttachments[0]?.id expect(id).toBeDefined() @@ -131,7 +131,7 @@ describe('useNativeChatComposerAttachments', () => { it('rescopes attachments when the scope key changes (composer reused for another pane)', async () => { const probe = await renderProbe('pty-1') await act(async () => { - probe.latest().attachLocalPaths(['/tmp/orca-native-chat-pane-1.png']) + probe.latest().attachResolvedPaths(['/tmp/orca-native-chat-pane-1.png']) }) expect(probe.latest().imageAttachments).toMatchObject([ { path: '/tmp/orca-native-chat-pane-1.png' } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index e64512fc9..60ecf44ff 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -30,7 +30,7 @@ export function useNativeChatComposerAttachments({ }: UseNativeChatComposerAttachmentsArgs): { imageAttachments: NativeChatComposerImageAttachment[] appendImageAttachments: (paths: string[]) => void - attachLocalPaths: (paths: string[]) => void + attachResolvedPaths: (paths: string[]) => void clearImageAttachments: () => void removeImageAttachment: (id: string) => void } { @@ -101,7 +101,10 @@ export function useNativeChatComposerAttachments({ [caret, setCaret, setDraft, setNotice, textareaRef] ) - const attachLocalPaths = useCallback( + // Attach paths the TARGET AGENT can read: local paths for local worktrees, + // already-uploaded remote paths for SSH worktrees (the composer uploads + // before calling this — see native-chat-attachment-upload.ts). + const attachResolvedPaths = useCallback( (paths: string[]) => { const target = resolveTarget() if (!target || nativeChatComposerTargetIsRemote(target.ptyId)) { @@ -131,7 +134,7 @@ export function useNativeChatComposerAttachments({ return { imageAttachments, appendImageAttachments, - attachLocalPaths, + attachResolvedPaths, clearImageAttachments: () => updateImageAttachments(() => []), removeImageAttachment: (id) => updateImageAttachments((prev) => prev.filter((attachment) => attachment.id !== id)) 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 000000000..144385e76 --- /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 a1eefa825..dbb6a06ed 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,8 +1,13 @@ 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' +import { + nativeChatWorktreeNotReadyNotice, + type NativeChatAttachmentOwner +} from './native-chat-attachment-upload' export type UseNativeChatComposerPasteArgs = { agent: AgentType @@ -10,7 +15,10 @@ export type UseNativeChatComposerPasteArgs = { * via a ref so a flip mid-paste doesn't write into a guarded composer. */ disabled: boolean caret: number - attachLocalPaths: (paths: string[]) => void + /** Resolved at paste time: SSH panes must save the clipboard image on the + * remote host, or the attached path names a file the agent cannot read. */ + resolveAttachmentOwner: () => NativeChatAttachmentOwner + attachResolvedPaths: (paths: string[]) => void insertTypedText: (text: string) => boolean setCaret: (caret: number) => void setNotice: (notice: string | null) => void @@ -44,7 +52,8 @@ export function useNativeChatComposerPaste({ agent, disabled, caret, - attachLocalPaths, + resolveAttachmentOwner, + attachResolvedPaths, insertTypedText, setCaret, setNotice @@ -58,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) @@ -70,10 +109,10 @@ export function useNativeChatComposerPaste({ ) return } - attachLocalPaths([result.path]) + attachResolvedPaths([result.path]) setNotice(null) }, - [agent, attachLocalPaths, setNotice] + [agent, attachResolvedPaths, setNotice] ) const handlePaste = useCallback( @@ -91,29 +130,50 @@ export function useNativeChatComposerPaste({ return } event.preventDefault() + const owner = resolveAttachmentOwner() + if (owner.kind === 'not-ready') { + setNotice(nativeChatWorktreeNotReadyNotice()) + return + } // Why: snapshot the caret before the async temp-file round-trip — `caret` // state can move (further typing/selection) while the await is in flight. const caretAtPaste = caret void (async () => { - const tempPath = await window.api.ui.saveClipboardImageAsTempFile().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, setCaret] + [ + attachClipboardImageTempFile, + caret, + resolveAttachmentOwner, + saveClipboardImageForOwner, + setCaret, + setNotice + ] ) const pasteFromClipboard = useCallback(() => { void (async () => { - const tempPath = await window.api.ui.saveClipboardImageAsTempFile().catch(() => null) - if (disabledRef.current) { + const owner = resolveAttachmentOwner() + // not-ready still saves locally: with no event in hand this is the only + // 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 saved = await saveClipboardImageForOwner(owner) + if (disabledRef.current || saved.status === 'failed') { return } - if (tempPath) { - attachClipboardImageTempFile(tempPath) + if (saved.status === 'saved') { + if (owner.kind === 'not-ready') { + setNotice(nativeChatWorktreeNotReadyNotice()) + return + } + attachClipboardImageTempFile(saved.tempPath) return } const text = await window.api.ui @@ -126,7 +186,13 @@ export function useNativeChatComposerPaste({ insertTypedText(text) } })() - }, [attachClipboardImageTempFile, insertTypedText]) + }, [ + attachClipboardImageTempFile, + insertTypedText, + resolveAttachmentOwner, + saveClipboardImageForOwner, + setNotice + ]) return { handlePaste, pasteFromClipboard } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx new file mode 100644 index 000000000..92f686768 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -0,0 +1,158 @@ +// @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' + +const mocks = vi.hoisted(() => ({ + resolveNativeChatAttachmentOwner: vi.fn(), + uploadNativeChatAttachmentPaths: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => ({}) } +})) + +vi.mock('./native-chat-attachment-upload', () => ({ + resolveNativeChatAttachmentOwner: mocks.resolveNativeChatAttachmentOwner, + uploadNativeChatAttachmentPaths: mocks.uploadNativeChatAttachmentPaths, + nativeChatWorktreeNotReadyNotice: () => 'Worktree not ready — try again in a moment.' +})) + +import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' + +type HookApi = ReturnType + +function Probe({ + disabled, + attachResolvedPaths, + setNotice, + onReady +}: { + disabled: boolean + attachResolvedPaths: (paths: string[]) => void + setNotice: (notice: string | null) => void + onReady: (api: HookApi) => void +}): null { + onReady( + useNativeChatExternalAttachments({ + terminalTabId: 'tab-1', + disabled, + attachResolvedPaths, + setNotice + }) + ) + return null +} + +let root: Root | null = null + +async function renderProbe(args: { + disabled?: boolean + attachResolvedPaths: (paths: string[]) => void + 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, + attachResolvedPaths: args.attachResolvedPaths, + 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 + } +} + +afterEach(() => { + root?.unmount() + root = null + vi.clearAllMocks() +}) + +describe('useNativeChatExternalAttachments', () => { + it('attaches local worktree paths unchanged', async () => { + mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'local' }) + const attachResolvedPaths = vi.fn() + const probe = await renderProbe({ attachResolvedPaths }) + await act(async () => { + probe.latest().attachExternalPaths(['/local/a.txt']) + }) + expect(attachResolvedPaths).toHaveBeenCalledWith(['/local/a.txt']) + expect(mocks.uploadNativeChatAttachmentPaths).not.toHaveBeenCalled() + }) + + it('uploads SSH worktree paths and attaches the remote results', async () => { + mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ + kind: 'ssh', + connectionId: 'conn-1', + worktreePath: '/remote/wt' + }) + mocks.uploadNativeChatAttachmentPaths.mockResolvedValue(['/remote/wt/.orca/drops/a.txt']) + const attachResolvedPaths = vi.fn() + const probe = await renderProbe({ attachResolvedPaths }) + await act(async () => { + probe.latest().attachExternalPaths(['/local/a.txt']) + }) + expect(mocks.uploadNativeChatAttachmentPaths).toHaveBeenCalledWith(['/local/a.txt'], { + kind: 'ssh', + connectionId: 'conn-1', + worktreePath: '/remote/wt' + }) + expect(attachResolvedPaths).toHaveBeenCalledWith(['/remote/wt/.orca/drops/a.txt']) + }) + + it('shows the not-ready notice instead of attaching unresolved paths', async () => { + mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'not-ready' }) + const attachResolvedPaths = vi.fn() + const setNotice = vi.fn() + const probe = await renderProbe({ attachResolvedPaths, setNotice }) + await act(async () => { + probe.latest().attachExternalPaths(['/local/a.txt']) + }) + expect(setNotice).toHaveBeenCalledWith('Worktree not ready — try again in a moment.') + expect(attachResolvedPaths).not.toHaveBeenCalled() + }) + + it('drops an upload that resolves after the composer became disabled', async () => { + mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ + kind: 'ssh', + connectionId: 'conn-1', + worktreePath: '/remote/wt' + }) + let resolveUpload: (paths: string[]) => void = () => {} + mocks.uploadNativeChatAttachmentPaths.mockReturnValue( + new Promise((resolve) => { + resolveUpload = resolve + }) + ) + const attachResolvedPaths = vi.fn() + const probe = await renderProbe({ attachResolvedPaths }) + await act(async () => { + probe.latest().attachExternalPaths(['/local/a.txt']) + }) + await probe.setDisabled(true) + await act(async () => { + resolveUpload(['/remote/wt/.orca/drops/a.txt']) + }) + expect(attachResolvedPaths).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts new file mode 100644 index 000000000..d2e19375e --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts @@ -0,0 +1,69 @@ +import { useCallback, useRef } from 'react' +import { useAppStore } from '@/store' +import { + nativeChatWorktreeNotReadyNotice, + resolveNativeChatAttachmentOwner, + uploadNativeChatAttachmentPaths, + type NativeChatAttachmentOwner +} from './native-chat-attachment-upload' + +export type UseNativeChatExternalAttachmentsArgs = { + terminalTabId: string + /** Live composer-disabled state; read at await-resume via a ref so a flip + * mid-upload doesn't attach into a guarded composer. */ + disabled: boolean + attachResolvedPaths: (paths: string[]) => void + setNotice: (notice: string | null) => void +} + +/** + * Attach paths that arrived client-local (composer drop / file picker). SSH + * worktrees upload into the worktree's `.orca/drops` first so the remote agent + * can actually read what gets referenced (STA-1465). + */ +export function useNativeChatExternalAttachments({ + terminalTabId, + disabled, + attachResolvedPaths, + setNotice +}: UseNativeChatExternalAttachmentsArgs): { + attachExternalPaths: (paths: string[]) => void + resolveAttachmentOwner: () => NativeChatAttachmentOwner +} { + const disabledRef = useRef(disabled) + disabledRef.current = disabled + + const resolveAttachmentOwner = useCallback( + () => resolveNativeChatAttachmentOwner(useAppStore.getState(), terminalTabId), + [terminalTabId] + ) + + const attachExternalPaths = useCallback( + (paths: string[]) => { + if (paths.length === 0) { + return + } + const owner = resolveAttachmentOwner() + if (owner.kind === 'not-ready') { + setNotice(nativeChatWorktreeNotReadyNotice()) + return + } + if (owner.kind !== 'ssh') { + // 'runtime' proceeds so attachResolvedPaths' existing remote-session + // gate reports the unsupported state. + attachResolvedPaths(paths) + return + } + void (async () => { + const remotePaths = await uploadNativeChatAttachmentPaths(paths, owner) + if (!remotePaths || remotePaths.length === 0 || disabledRef.current) { + return + } + attachResolvedPaths(remotePaths) + })() + }, + [attachResolvedPaths, resolveAttachmentOwner, setNotice] + ) + + return { attachExternalPaths, resolveAttachmentOwner } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d0dc2e8de..79bb974f1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12422,7 +12422,10 @@ "noSkills": "No matching skills", "localAttachmentUnsupported": "Local attachments are not available for remote sessions.", "removeAttachment": "Remove attachment", - "pastedImageLabel": "Pasted image" + "pastedImageLabel": "Pasted image", + "imagePasteFailed": "Image paste failed.", + "worktreeNotReady": "Worktree not ready — try again in a moment.", + "uploadingAttachments": "Uploading {{value0}} file{{value1}} to remote…" }, "tool": { "running": "Running…", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 8923d561d..d2978db89 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -12422,7 +12422,10 @@ "noSkills": "Sin habilidades coincidentes", "localAttachmentUnsupported": "Los archivos adjuntos locales no están disponibles para sesiones remotas.", "removeAttachment": "Quitar archivo adjunto", - "pastedImageLabel": "Imagen pegada" + "pastedImageLabel": "Imagen pegada", + "imagePasteFailed": "Image paste failed.", + "worktreeNotReady": "Worktree not ready — try again in a moment.", + "uploadingAttachments": "Uploading {{value0}} file{{value1}} to remote…" }, "tool": { "running": "Ejecutando…", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e5bdf3384..39085e01b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -12422,7 +12422,10 @@ "noSkills": "一致するスキルがありません", "localAttachmentUnsupported": "ローカル添付ファイルはリモート セッションでは使用できません。", "removeAttachment": "添付ファイルを削除する", - "pastedImageLabel": "貼り付けた画像" + "pastedImageLabel": "貼り付けた画像", + "imagePasteFailed": "Image paste failed.", + "worktreeNotReady": "Worktree not ready — try again in a moment.", + "uploadingAttachments": "Uploading {{value0}} file{{value1}} to remote…" }, "tool": { "running": "実行中…", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index db8a2aea5..98ba88a10 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -12422,7 +12422,10 @@ "noSkills": "어울리는 스킬 없음", "localAttachmentUnsupported": "원격 세션에는 로컬 첨부 파일을 사용할 수 없습니다.", "removeAttachment": "첨부파일 삭제", - "pastedImageLabel": "붙여넣은 이미지" + "pastedImageLabel": "붙여넣은 이미지", + "imagePasteFailed": "Image paste failed.", + "worktreeNotReady": "Worktree not ready — try again in a moment.", + "uploadingAttachments": "Uploading {{value0}} file{{value1}} to remote…" }, "tool": { "running": "실행 중…", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d6cce4de2..6e3296b14 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -12422,7 +12422,10 @@ "noSkills": "没有匹配的技能", "localAttachmentUnsupported": "远程会话不支持本地附件。", "removeAttachment": "移除附件", - "pastedImageLabel": "粘贴的图片" + "pastedImageLabel": "粘贴的图片", + "imagePasteFailed": "Image paste failed.", + "worktreeNotReady": "Worktree not ready — try again in a moment.", + "uploadingAttachments": "Uploading {{value0}} file{{value1}} to remote…" }, "tool": { "running": "正在运行…",