feat(native-chat): upload composer attachments over SSH (#7832)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-07-08 16:56:16 -07:00 committed by GitHub
parent 20832ef36f
commit 95f3933ea2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 846 additions and 30 deletions

View File

@ -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<NativeChatComposerHandle, NativeCha
setCaret(el.selectionStart ?? el.value.length)
}, [])
const { imageAttachments, attachLocalPaths, clearImageAttachments, removeImageAttachment } =
const { imageAttachments, attachResolvedPaths, clearImageAttachments, removeImageAttachment } =
useNativeChatComposerAttachments({
attachmentScopeKey: targetPtyId ?? terminalTabId,
caret,
@ -213,11 +214,19 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
return true
}, [])
const { attachExternalPaths, resolveAttachmentOwner } = useNativeChatExternalAttachments({
terminalTabId,
disabled,
attachResolvedPaths,
setNotice
})
const { handlePaste, pasteFromClipboard } = useNativeChatComposerPaste({
agent,
disabled,
caret,
attachLocalPaths,
resolveAttachmentOwner,
attachResolvedPaths,
insertTypedText,
setCaret,
setNotice
@ -234,9 +243,9 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
if (payload.target !== NATIVE_FILE_DROP_TARGET.composer) {
return
}
attachLocalPaths(payload.paths)
attachExternalPaths(payload.paths)
})
}, [attachLocalPaths])
}, [attachExternalPaths])
const pickAttachment = useCallback(() => {
void (async () => {
@ -244,9 +253,9 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
if (!filePath) {
return
}
attachLocalPaths([filePath])
attachExternalPaths([filePath])
})()
}, [attachLocalPaths])
}, [attachExternalPaths])
const focusForDictation = useCallback(() => {
textareaRef.current?.focus()

View File

@ -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> = {}): 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> = {}): 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')
})
})

View File

@ -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<string[] | null> {
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)
}
}

View File

@ -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' }

View File

@ -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))

View File

@ -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()
})
})

View File

@ -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 }
}

View File

@ -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<typeof useNativeChatExternalAttachments>
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<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,
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<string[]>((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()
})
})

View File

@ -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 }
}

View File

@ -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…",

View File

@ -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…",

View File

@ -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": "実行中…",

View File

@ -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": "실행 중…",

View File

@ -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": "正在运行…",