diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts index 9671eb7a09c..4791847271a 100644 --- a/src/main/ipc/shell.test.ts +++ b/src/main/ipc/shell.test.ts @@ -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') diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 7ed49c4af7c..96aa6e209f4 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -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 => { + const result = await dialog.showOpenDialog({ + properties: ['openFile', 'multiSelections'] + }) + return result.canceled ? [] : result.filePaths + }) + // Why: window.prompt() and 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 => { diff --git a/src/preload/api/shell-api.ts b/src/preload/api/shell-api.ts index 49e811aeb65..549408c8e05 100644 --- a/src/preload/api/shell-api.ts +++ b/src/preload/api/shell-api.ts @@ -22,6 +22,7 @@ export type ShellApi = { pathsExist?: (paths: string[]) => Promise pathExists: (path: string) => Promise pickAttachment: () => Promise + pickAttachments: () => Promise pickImage: () => Promise pickRepoIconImage: () => Promise<{ dataUrl: string diff --git a/src/preload/api/shell-bridge.ts b/src/preload/api/shell-bridge.ts index 6cd250042a6..57fa2254751 100644 --- a/src/preload/api/shell-bridge.ts +++ b/src/preload/api/shell-bridge.ts @@ -29,6 +29,8 @@ export const shellApi = { pickAttachment: (): Promise => ipcRenderer.invoke('shell:pickAttachment'), + pickAttachments: (): Promise => ipcRenderer.invoke('shell:pickAttachments'), + pickImage: (): Promise => ipcRenderer.invoke('shell:pickImage'), pickRepoIconImage: (): Promise<{ dataUrl: string; fileName: string } | null> => diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 853309887d8..44a6d8b865d 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -218,7 +218,7 @@ const NativeChatComposerPane = forwardRef textarea?.setSelectionRange(result.caret, result.caret)) }} onRemoveImageAttachment={(id) => removeImageAttachment(id)} - onAttach={pickAttachment} + onAttach={pickAttachments} onDictationToggle={dictation.toggleDictation} onDictationHoldStart={dictation.startHoldDictation} onDictationHoldEnd={dictation.stopHoldDictation} diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index a77a0e207f8..ad09b01ead4 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -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', diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx index 3c63879d198..c72ab81f475 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -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() diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts index 5e88f793843..5a0189622a4 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts @@ -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. diff --git a/src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.test.tsx new file mode 100644 index 00000000000..19e614f65a1 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.test.tsx @@ -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 +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' + ]) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.ts b/src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.ts index 1a34f43cd51..5165c00cb01 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.ts @@ -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 } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d58131e098b..414d2479711 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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}}", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c5df8232fea..30ad9367ae6 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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…", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 133c89d94e1..18780b14c81 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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…", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 65968827443..3ba56e7d2f1 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17766,7 +17766,8 @@ "imagePreviewUnavailable": "プレビューは利用できません", "workspaceAttachmentMismatch": "ファイルはソースワークスペースにのみ添付できます。", "attachmentOwnerChanged": "このワークスペースは接続中にホストを変更しました。ファイルを再度ドロップしてください。", - "attachmentUnreadable": "ドロップされたファイルを読み取れませんでした。" + "attachmentUnreadable": "ドロップされたファイルを読み取れませんでした。", + "tooManyAttachments": "一度に添付できるファイルは {{value0}} 個以下です。" }, "tool": { "running": "実行中…", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a82dc0900bc..6a7fdd244c7 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17766,7 +17766,8 @@ "imagePreviewUnavailable": "미리보기를 사용할 수 없습니다.", "workspaceAttachmentMismatch": "파일은 소스 워크스페이스에만 첨부할 수 있습니다.", "attachmentOwnerChanged": "이 워크스페이스는 첨부하는 동안 호스트를 변경했습니다. 파일을 다시 삭제하세요.", - "attachmentUnreadable": "삭제된 파일을 읽을 수 없습니다." + "attachmentUnreadable": "삭제된 파일을 읽을 수 없습니다.", + "tooManyAttachments": "한 번에 {{value0}}개 이하의 파일을 첨부하세요." }, "tool": { "running": "실행 중…", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4bb136cb328..eeaf83edd00 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17731,7 +17731,8 @@ "imagePreviewUnavailable": "预览不可用", "workspaceAttachmentMismatch": "文件只能附加到其源工作区。", "attachmentOwnerChanged": "此工作区在附加时更改了主机 - 再次删除文件。", - "attachmentUnreadable": "无法读取删除的文件。" + "attachmentUnreadable": "无法读取删除的文件。", + "tooManyAttachments": "每次最多附加 {{value0}} 个文件。" }, "tool": { "running": "正在运行…", diff --git a/src/renderer/src/web/preload-api/web-shell-api.ts b/src/renderer/src/web/preload-api/web-shell-api.ts index 138ebe9abb4..935397fd20e 100644 --- a/src/renderer/src/web/preload-api/web-shell-api.ts +++ b/src/renderer/src/web/preload-api/web-shell-api.ts @@ -25,6 +25,7 @@ export function createShellApi(): NonNullable['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),