feat(native-chat): upload composer attachments over SSH (#7832)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
20832ef36f
commit
95f3933ea2
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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…",
|
||||
|
|
|
|||
|
|
@ -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…",
|
||||
|
|
|
|||
|
|
@ -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": "実行中…",
|
||||
|
|
|
|||
|
|
@ -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": "실행 중…",
|
||||
|
|
|
|||
|
|
@ -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": "正在运行…",
|
||||
|
|
|
|||
Loading…
Reference in New Issue