mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
feat(native-chat): attach several files from one picker selection (#23956)
* feat(native-chat): attach several files from one picker selection Picks over 256 files are rejected whole, matching the drop limit. Fixes #23905 * refactor(native-chat): name the composer action for the files it now attaches The hook still returned `pickAttachment` after it started attaching a whole selection. Also records why the plural IPC handler sits next to the singular one, matching its siblings in the same file. * test(native-chat): cover the paperclip forwarding every picked path The one line that makes the feature work — the composer handing the whole picker result to attachExternalPaths — had no test; reverting it to paths[0] kept every existing test green. Adds a hook test for the multi-path, canceled, and wrong-pane cases, plus the missing canceled case for the shell:pickAttachments handler. * test(native-chat): exercise multi-pick through renderHook * test(settings): preserve label fallback and verify Spanish copy Keep latest main's missing-label fixture and restore its translation namespace after cleanup, so later tests still load real catalogs. Independently assert the shipped Spanish resource and rendered supported-agents label. --------- Co-authored-by: Neil <neil@stably.ai> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
co-authored by
Neil
Neil
Brennan Benson
parent
4c35516150
commit
81a1968bc6
@@ -170,6 +170,32 @@ describe('registerShellHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('picks several attachment files in one native dialog', async () => {
|
||||
showOpenDialogMock.mockResolvedValue({
|
||||
canceled: false,
|
||||
filePaths: ['/Users/kaylee/notes.md', '/Users/kaylee/diagram.png']
|
||||
})
|
||||
|
||||
const handler = getHandler('shell:pickAttachments')
|
||||
await expect(handler({})).resolves.toEqual([
|
||||
'/Users/kaylee/notes.md',
|
||||
'/Users/kaylee/diagram.png'
|
||||
])
|
||||
expect(showOpenDialogMock).toHaveBeenCalledWith({
|
||||
properties: ['openFile', 'multiSelections']
|
||||
})
|
||||
})
|
||||
|
||||
it('returns no attachment paths when multi-file picking is canceled', async () => {
|
||||
showOpenDialogMock.mockResolvedValue({
|
||||
canceled: true,
|
||||
filePaths: ['/Users/kaylee/notes.md']
|
||||
})
|
||||
|
||||
const handler = getHandler('shell:pickAttachments')
|
||||
await expect(handler({})).resolves.toEqual([])
|
||||
})
|
||||
|
||||
describe('shell:openPath', () => {
|
||||
it('ignores relative paths', async () => {
|
||||
const handler = getHandler('shell:openPath')
|
||||
|
||||
@@ -242,6 +242,15 @@ export function registerShellHandlers(store: Store): void {
|
||||
return result.filePaths[0]
|
||||
})
|
||||
|
||||
// Why: a separate plural handler, like repos:pickFolder/pickFolders — callers that
|
||||
// must take exactly one file (the notebook interpreter picker) keep pickAttachment.
|
||||
ipcMain.handle('shell:pickAttachments', async (): Promise<string[]> => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openFile', 'multiSelections']
|
||||
})
|
||||
return result.canceled ? [] : result.filePaths
|
||||
})
|
||||
|
||||
// Why: window.prompt() and <input type="file"> are unreliable in Electron,
|
||||
// so we use the native OS dialog to let the user pick an image file.
|
||||
ipcMain.handle('shell:pickImage', async (): Promise<string | null> => {
|
||||
|
||||
@@ -22,6 +22,7 @@ export type ShellApi = {
|
||||
pathsExist?: (paths: string[]) => Promise<boolean[]>
|
||||
pathExists: (path: string) => Promise<boolean>
|
||||
pickAttachment: () => Promise<string | null>
|
||||
pickAttachments: () => Promise<string[]>
|
||||
pickImage: () => Promise<string | null>
|
||||
pickRepoIconImage: () => Promise<{
|
||||
dataUrl: string
|
||||
|
||||
@@ -29,6 +29,8 @@ export const shellApi = {
|
||||
|
||||
pickAttachment: (): Promise<string | null> => ipcRenderer.invoke('shell:pickAttachment'),
|
||||
|
||||
pickAttachments: (): Promise<string[]> => ipcRenderer.invoke('shell:pickAttachments'),
|
||||
|
||||
pickImage: (): Promise<string | null> => ipcRenderer.invoke('shell:pickImage'),
|
||||
|
||||
pickRepoIconImage: (): Promise<{ dataUrl: string; fileName: string } | null> =>
|
||||
|
||||
@@ -218,7 +218,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
setNotice
|
||||
})
|
||||
|
||||
const { pickAttachment } = useNativeChatFileAttachmentActions(paneKey, attachExternalPaths)
|
||||
const { pickAttachments } = useNativeChatFileAttachmentActions(paneKey, attachExternalPaths)
|
||||
const dictation = useNativeChatDictation(textareaRef)
|
||||
const { dispatch: dispatchSessionOptionCommand, isDispatching: isDispatchingSessionOption } =
|
||||
useNativeChatSessionOptionCommand({
|
||||
@@ -411,7 +411,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
requestAnimationFrame(() => textarea?.setSelectionRange(result.caret, result.caret))
|
||||
}}
|
||||
onRemoveImageAttachment={(id) => removeImageAttachment(id)}
|
||||
onAttach={pickAttachment}
|
||||
onAttach={pickAttachments}
|
||||
onDictationToggle={dictation.toggleDictation}
|
||||
onDictationHoldStart={dictation.startHoldDictation}
|
||||
onDictationHoldEnd={dictation.stopHoldDictation}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop'
|
||||
import {
|
||||
findTerminalTabWorktreeId,
|
||||
resolveNativeChatFileLinkContext
|
||||
@@ -117,6 +118,14 @@ export function nativeChatAttachmentUnreadableNotice(): string {
|
||||
)
|
||||
}
|
||||
|
||||
export function nativeChatTooManyAttachmentsNotice(): string {
|
||||
return translate(
|
||||
'components.native-chat.composer.tooManyAttachments',
|
||||
'Attach {{value0}} or fewer files at a time.',
|
||||
{ value0: NATIVE_FILE_DROP_MAX_PATHS }
|
||||
)
|
||||
}
|
||||
|
||||
export function nativeChatLocalAttachmentUnsupportedNotice(): string {
|
||||
return translate(
|
||||
'components.native-chat.composer.localAttachmentUnsupported',
|
||||
|
||||
@@ -61,6 +61,9 @@ function Probe({
|
||||
return null
|
||||
}
|
||||
|
||||
// One past NATIVE_FILE_DROP_MAX_PATHS.
|
||||
const pathsOverDropLimit = Array.from({ length: 257 }, (_, index) => `/external/${index}.png`)
|
||||
|
||||
let root: Root | null = null
|
||||
|
||||
async function renderProbe(args: {
|
||||
@@ -141,6 +144,31 @@ describe('useNativeChatExternalAttachments', () => {
|
||||
expect(mocks.uploadNativeChatAttachmentPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a whole batch over the drop path limit, like a drop does', async () => {
|
||||
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'local' })
|
||||
const attachResolvedPaths = vi.fn()
|
||||
const setNotice = vi.fn()
|
||||
const probe = await renderProbe({ attachResolvedPaths, setNotice })
|
||||
await act(async () => {
|
||||
probe.latest().attachExternalPaths(pathsOverDropLimit)
|
||||
})
|
||||
expect(setNotice).toHaveBeenCalledExactlyOnceWith('Attach 256 or fewer files at a time.')
|
||||
expect(mocks.stat).not.toHaveBeenCalled()
|
||||
expect(attachResolvedPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a remote runtime before the path limit, since trimming would not help', async () => {
|
||||
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'runtime' })
|
||||
const setNotice = vi.fn()
|
||||
const probe = await renderProbe({ attachResolvedPaths: vi.fn(), setNotice })
|
||||
await act(async () => {
|
||||
probe.latest().attachExternalPaths(pathsOverDropLimit)
|
||||
})
|
||||
expect(setNotice).toHaveBeenCalledExactlyOnceWith(
|
||||
'Local attachments are not available for remote sessions.'
|
||||
)
|
||||
})
|
||||
|
||||
it('waits for the local file check and skips rejected paths without blocking other files', async () => {
|
||||
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'local' })
|
||||
const fileCheck = deferred<void>()
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop'
|
||||
import { nativeChatAttachmentOwnerUnchanged } from './native-chat-resolved-path-ownership'
|
||||
import {
|
||||
nativeChatAttachmentOwnerChangedNotice,
|
||||
nativeChatAttachmentUnreadableNotice,
|
||||
nativeChatLocalAttachmentUnsupportedNotice,
|
||||
nativeChatTooManyAttachmentsNotice,
|
||||
nativeChatWorktreeNotReadyNotice,
|
||||
resolveNativeChatAttachmentOwner,
|
||||
resolveNativeChatAttachmentOwnerForWorktree,
|
||||
@@ -84,6 +86,11 @@ export function useNativeChatExternalAttachments({
|
||||
setNotice(nativeChatLocalAttachmentUnsupportedNotice())
|
||||
return
|
||||
}
|
||||
// The picker has no native cap, so it gets the same all-or-nothing limit as a drop.
|
||||
if (paths.length > NATIVE_FILE_DROP_MAX_PATHS) {
|
||||
setNotice(nativeChatTooManyAttachmentsNotice())
|
||||
return
|
||||
}
|
||||
// Why every exit reports: a drop that reaches here and produces nothing is
|
||||
// the silent-failure complaint in #15782. Only a disabled composer stays
|
||||
// quiet — it is being torn down or guarded, and has no notice surface.
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act } from 'react'
|
||||
import { cleanup, renderHook } from '@testing-library/react'
|
||||
import {
|
||||
NATIVE_FILE_DROP_TARGET,
|
||||
type NativeFileDropPayload
|
||||
} from '../../../../shared/native-file-drop'
|
||||
import { useNativeChatFileAttachmentActions } from './use-native-chat-file-attachment-actions'
|
||||
|
||||
const SCOPE_KEY = 'pane-1'
|
||||
|
||||
let pickAttachments: ReturnType<typeof vi.fn>
|
||||
type DropListener = (payload: NativeFileDropPayload) => void
|
||||
|
||||
let dropListeners: DropListener[] = []
|
||||
function renderProbe(
|
||||
attachExternalPaths: (paths: string[]) => void
|
||||
): () => { pickAttachments: () => void } {
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatFileAttachmentActions(SCOPE_KEY, attachExternalPaths)
|
||||
)
|
||||
return () => result.current
|
||||
}
|
||||
|
||||
describe('useNativeChatFileAttachmentActions', () => {
|
||||
beforeEach(() => {
|
||||
dropListeners = []
|
||||
pickAttachments = vi.fn()
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
shell: { pickAttachments },
|
||||
ui: {
|
||||
onFileDrop: (listener: DropListener) => {
|
||||
dropListeners.push(listener)
|
||||
return () => {
|
||||
dropListeners = dropListeners.filter((entry) => entry !== listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
it('attaches every path the picker returns, not just the first', async () => {
|
||||
pickAttachments.mockResolvedValue(['/picked/notes.md', '/picked/diagram.png'])
|
||||
const attachExternalPaths = vi.fn()
|
||||
const latest = renderProbe(attachExternalPaths)
|
||||
await act(async () => {
|
||||
latest().pickAttachments()
|
||||
})
|
||||
expect(attachExternalPaths).toHaveBeenCalledExactlyOnceWith([
|
||||
'/picked/notes.md',
|
||||
'/picked/diagram.png'
|
||||
])
|
||||
})
|
||||
|
||||
it('attaches nothing when the picker is canceled', async () => {
|
||||
pickAttachments.mockResolvedValue([])
|
||||
const attachExternalPaths = vi.fn()
|
||||
const latest = renderProbe(attachExternalPaths)
|
||||
await act(async () => {
|
||||
latest().pickAttachments()
|
||||
})
|
||||
// Whether the empty batch is forwarded or dropped here, no file may attach.
|
||||
expect(attachExternalPaths.mock.calls.flatMap(([paths]) => paths)).toEqual([])
|
||||
})
|
||||
|
||||
it('only attaches a drop aimed at this pane', async () => {
|
||||
const attachExternalPaths = vi.fn()
|
||||
renderProbe(attachExternalPaths)
|
||||
await act(async () => {
|
||||
for (const listener of dropListeners) {
|
||||
listener({
|
||||
target: NATIVE_FILE_DROP_TARGET.composer,
|
||||
scopeKey: 'other-pane',
|
||||
paths: ['/dropped/a.png']
|
||||
})
|
||||
listener({
|
||||
target: NATIVE_FILE_DROP_TARGET.composer,
|
||||
scopeKey: SCOPE_KEY,
|
||||
paths: ['/dropped/a.png', '/dropped/b.png']
|
||||
})
|
||||
}
|
||||
})
|
||||
expect(attachExternalPaths).toHaveBeenCalledExactlyOnceWith([
|
||||
'/dropped/a.png',
|
||||
'/dropped/b.png'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@ export function useNativeChatFileAttachmentActions(
|
||||
/** Pane identity published as `data-composer-scope-key` on the drop target. */
|
||||
scopeKey: string,
|
||||
attachExternalPaths: (paths: string[]) => void
|
||||
): { pickAttachment: () => void } {
|
||||
): { pickAttachments: () => void } {
|
||||
useEffect(
|
||||
() =>
|
||||
window.api.ui.onFileDrop((payload) => {
|
||||
@@ -18,14 +18,11 @@ export function useNativeChatFileAttachmentActions(
|
||||
[attachExternalPaths, scopeKey]
|
||||
)
|
||||
|
||||
const pickAttachment = useCallback(() => {
|
||||
const pickAttachments = useCallback(() => {
|
||||
void (async () => {
|
||||
const filePath = await window.api.shell.pickAttachment()
|
||||
if (filePath) {
|
||||
attachExternalPaths([filePath])
|
||||
}
|
||||
attachExternalPaths(await window.api.shell.pickAttachments())
|
||||
})()
|
||||
}, [attachExternalPaths])
|
||||
|
||||
return { pickAttachment }
|
||||
return { pickAttachments }
|
||||
}
|
||||
|
||||
@@ -17991,7 +17991,8 @@
|
||||
"imagePreviewUnavailable": "Preview unavailable",
|
||||
"workspaceAttachmentMismatch": "Files can only be attached to their source workspace.",
|
||||
"attachmentOwnerChanged": "This workspace changed hosts while attaching — drop the files again.",
|
||||
"attachmentUnreadable": "Couldn't read the dropped files."
|
||||
"attachmentUnreadable": "Couldn't read the dropped files.",
|
||||
"tooManyAttachments": "Attach {{value0}} or fewer files at a time."
|
||||
},
|
||||
"tool": {
|
||||
"exitCode": "exit {{value0}}",
|
||||
|
||||
@@ -17731,7 +17731,8 @@
|
||||
"imagePreview": "Vista previa de la imagen a tamaño completo",
|
||||
"workspaceAttachmentMismatch": "Los archivos solo se pueden adjuntar a su espacio de trabajo de origen.",
|
||||
"attachmentOwnerChanged": "Este espacio de trabajo cambió de host durante la conexión; vuelve a soltar los archivos.",
|
||||
"attachmentUnreadable": "No se pudieron leer los archivos soltados."
|
||||
"attachmentUnreadable": "No se pudieron leer los archivos soltados.",
|
||||
"tooManyAttachments": "Adjunta {{value0}} archivos o menos a la vez."
|
||||
},
|
||||
"tool": {
|
||||
"running": "Ejecutando…",
|
||||
|
||||
@@ -17830,7 +17830,8 @@
|
||||
"imagePreviewUnavailable": "Aperçu indisponible",
|
||||
"workspaceAttachmentMismatch": "Les fichiers ne peuvent être joints qu'à leur espace de travail source.",
|
||||
"attachmentOwnerChanged": "Cet espace de travail a changé d'hôte lors de la connexion : supprimez à nouveau les fichiers.",
|
||||
"attachmentUnreadable": "Impossible de lire les fichiers supprimés."
|
||||
"attachmentUnreadable": "Impossible de lire les fichiers supprimés.",
|
||||
"tooManyAttachments": "Joignez {{value0}} fichiers maximum à la fois."
|
||||
},
|
||||
"tool": {
|
||||
"running": "En cours…",
|
||||
|
||||
@@ -17766,7 +17766,8 @@
|
||||
"imagePreviewUnavailable": "プレビューは利用できません",
|
||||
"workspaceAttachmentMismatch": "ファイルはソースワークスペースにのみ添付できます。",
|
||||
"attachmentOwnerChanged": "このワークスペースは接続中にホストを変更しました。ファイルを再度ドロップしてください。",
|
||||
"attachmentUnreadable": "ドロップされたファイルを読み取れませんでした。"
|
||||
"attachmentUnreadable": "ドロップされたファイルを読み取れませんでした。",
|
||||
"tooManyAttachments": "一度に添付できるファイルは {{value0}} 個以下です。"
|
||||
},
|
||||
"tool": {
|
||||
"running": "実行中…",
|
||||
|
||||
@@ -17766,7 +17766,8 @@
|
||||
"imagePreviewUnavailable": "미리보기를 사용할 수 없습니다.",
|
||||
"workspaceAttachmentMismatch": "파일은 소스 워크스페이스에만 첨부할 수 있습니다.",
|
||||
"attachmentOwnerChanged": "이 워크스페이스는 첨부하는 동안 호스트를 변경했습니다. 파일을 다시 삭제하세요.",
|
||||
"attachmentUnreadable": "삭제된 파일을 읽을 수 없습니다."
|
||||
"attachmentUnreadable": "삭제된 파일을 읽을 수 없습니다.",
|
||||
"tooManyAttachments": "한 번에 {{value0}}개 이하의 파일을 첨부하세요."
|
||||
},
|
||||
"tool": {
|
||||
"running": "실행 중…",
|
||||
|
||||
@@ -17731,7 +17731,8 @@
|
||||
"imagePreviewUnavailable": "预览不可用",
|
||||
"workspaceAttachmentMismatch": "文件只能附加到其源工作区。",
|
||||
"attachmentOwnerChanged": "此工作区在附加时更改了主机 - 再次删除文件。",
|
||||
"attachmentUnreadable": "无法读取删除的文件。"
|
||||
"attachmentUnreadable": "无法读取删除的文件。",
|
||||
"tooManyAttachments": "每次最多附加 {{value0}} 个文件。"
|
||||
},
|
||||
"tool": {
|
||||
"running": "正在运行…",
|
||||
|
||||
@@ -25,6 +25,7 @@ export function createShellApi(): NonNullable<Partial<PreloadApi>['shell']> {
|
||||
// Without this the fallback proxy answers `undefined` and the caller's batch rejects.
|
||||
pathsExist: (paths) => Promise.all(paths.map((path) => pathExistsOnRuntime(path))),
|
||||
pickAttachment: () => Promise.resolve(null),
|
||||
pickAttachments: () => Promise.resolve([]),
|
||||
pickImage: () => Promise.resolve(null),
|
||||
pickRepoIconImage: () => Promise.resolve(null),
|
||||
pickAudio: () => Promise.resolve(null),
|
||||
|
||||
Reference in New Issue
Block a user