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:
Kelvin Amoaba
2026-10-06 12:31:51 -07:00
committed by GitHub
co-authored by Neil Neil Brennan Benson
parent 4c35516150
commit 81a1968bc6
17 changed files with 195 additions and 15 deletions
+26
View File
@@ -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')
+9
View File
@@ -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> => {
+1
View File
@@ -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
+2
View File
@@ -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.
@@ -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 }
}
+2 -1
View File
@@ -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}}",
+2 -1
View File
@@ -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…",
+2 -1
View File
@@ -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…",
+2 -1
View File
@@ -17766,7 +17766,8 @@
"imagePreviewUnavailable": "プレビューは利用できません",
"workspaceAttachmentMismatch": "ファイルはソースワークスペースにのみ添付できます。",
"attachmentOwnerChanged": "このワークスペースは接続中にホストを変更しました。ファイルを再度ドロップしてください。",
"attachmentUnreadable": "ドロップされたファイルを読み取れませんでした。"
"attachmentUnreadable": "ドロップされたファイルを読み取れませんでした。",
"tooManyAttachments": "一度に添付できるファイルは {{value0}} 個以下です。"
},
"tool": {
"running": "実行中…",
+2 -1
View File
@@ -17766,7 +17766,8 @@
"imagePreviewUnavailable": "미리보기를 사용할 수 없습니다.",
"workspaceAttachmentMismatch": "파일은 소스 워크스페이스에만 첨부할 수 있습니다.",
"attachmentOwnerChanged": "이 워크스페이스는 첨부하는 동안 호스트를 변경했습니다. 파일을 다시 삭제하세요.",
"attachmentUnreadable": "삭제된 파일을 읽을 수 없습니다."
"attachmentUnreadable": "삭제된 파일을 읽을 수 없습니다.",
"tooManyAttachments": "한 번에 {{value0}}개 이하의 파일을 첨부하세요."
},
"tool": {
"running": "실행 중…",
+2 -1
View File
@@ -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),