diff --git a/src/main/ipc/filesystem-import-local.ts b/src/main/ipc/filesystem-import-local.ts index 49b910dac5f..1df2b3f2fb3 100644 --- a/src/main/ipc/filesystem-import-local.ts +++ b/src/main/ipc/filesystem-import-local.ts @@ -2,7 +2,7 @@ import { lstat, rm } from 'node:fs/promises' import { basename, join, resolve } from 'node:path' import { authorizeExternalPath } from './filesystem-auth' import { isENOENT } from './filesystem-path-containment' -import type { ImportItemResult } from './filesystem-import-result-types' +import type { ImportItemResult } from '../../shared/filesystem-import-result-types' import { copyLocalFileNoFollow, preScanForSymlinks, diff --git a/src/main/ipc/filesystem-import-ssh.ts b/src/main/ipc/filesystem-import-ssh.ts index ae17f3d8e19..9268c6ece24 100644 --- a/src/main/ipc/filesystem-import-ssh.ts +++ b/src/main/ipc/filesystem-import-ssh.ts @@ -5,7 +5,7 @@ import { isENOENT } from './filesystem-path-containment' import { getSshConnectionManager } from './ssh' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import type { FileUploadSession, IFilesystemProvider } from '../providers/types' -import type { ImportItemResult } from './filesystem-import-result-types' +import type { ImportItemResult } from '../../shared/filesystem-import-result-types' import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index 7cce83aa3e5..ad1308f0c30 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -16,7 +16,7 @@ import type { ImportSkipReason, ResolveDroppedPathsResult, StagedExternalImportSource -} from './filesystem-import-result-types' +} from '../../shared/filesystem-import-result-types' import { importOneSource } from './filesystem-import-local' import { stagedRuntimeUploadByteLength, diff --git a/src/main/ipc/filesystem-runtime-upload-staging.ts b/src/main/ipc/filesystem-runtime-upload-staging.ts index 86b5f884820..6531f8a499a 100644 --- a/src/main/ipc/filesystem-runtime-upload-staging.ts +++ b/src/main/ipc/filesystem-runtime-upload-staging.ts @@ -11,7 +11,7 @@ import { isENOENT } from './filesystem-path-containment' import type { StagedExternalImportEntry, StagedExternalImportSource -} from './filesystem-import-result-types' +} from '../../shared/filesystem-import-result-types' class RuntimeUploadSymlinkError extends Error {} diff --git a/src/main/runtime/__fixtures__/omp-native-title-win32.meta.json b/src/main/runtime/__fixtures__/omp-native-title-win32.meta.json index 8594baa7630..6f64734a888 100644 --- a/src/main/runtime/__fixtures__/omp-native-title-win32.meta.json +++ b/src/main/runtime/__fixtures__/omp-native-title-win32.meta.json @@ -1,11 +1,7 @@ { "capturedAt": "2026-09-14T11:25:01.730Z", "platform": "darwin", - "command": [ - "bun", - "tests/tools/omp-native-title-capture.mjs", - "" - ], + "command": ["bun", "tests/tools/omp-native-title-capture.mjs", ""], "cols": 100, "rows": 30, "note": "OMP source ne7546987ca526eac8f605fac19ef9805b8f01898 buildTerminalTitleWithState; explicit win32 argument on macOS PTY, synthetic state transitions, no model/account. Not a Windows runtime capture.", diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 21acebad53a..acb49f500d3 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -5,6 +5,11 @@ import type { FsChangedPayload, MarkdownDocument } from '../../shared/filesystem-entry-types' +import type { + ImportItemResult, + ResolveDroppedPathsResult, + StagedExternalImportSource +} from '../../shared/filesystem-import-result-types' import type { LocalLogTailChangedPayload, LocalLogTailReadArgs, @@ -12,10 +17,7 @@ import type { LocalLogTailWatchArgs } from '../../shared/local-log-tail-types' import type { SshMutationExpectation } from '../../shared/ssh-types' -import type { - RuntimeUploadFileStreamRequest, - StageRuntimeUploadResult -} from '../../shared/runtime-upload-staging-contract' +import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' export type ExportApi = { htmlToPdf: (args: { @@ -138,30 +140,10 @@ export type FilesystemApi = { connectionId?: string ensureDir?: boolean } & SshMutationExpectation - ) => Promise<{ - results: ( - | { - sourcePath: string - status: 'imported' - destPath: string - kind: 'file' | 'directory' - renamed: boolean - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - )[] - }> + ) => Promise<{ results: ImportItemResult[] }> stageExternalPathsForRuntimeUpload: (args: { sourcePaths: string[] - }) => Promise + }) => Promise<{ sources: StagedExternalImportSource[] }> uploadExternalFileToRuntime: ( args: RuntimeUploadFileStreamRequest ) => Promise<{ byteLength: number }> @@ -171,14 +153,7 @@ export type FilesystemApi = { worktreePath: string connectionId?: string } & SshMutationExpectation - ) => Promise<{ - resolvedPaths: string[] - skipped: { - sourcePath: string - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - }[] - failed: { sourcePath: string; reason: string }[] - }> + ) => Promise watchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise unwatchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise onFsChanged: (callback: (payload: FsChangedPayload) => void) => () => void diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index 05c6eaee477..2f702d9464f 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -1,12 +1,14 @@ import type { PathExistenceResult } from '../../shared/path-existence-batch' import { ipcRenderer } from 'electron' import type { SshMutationExpectation } from '../../shared/ssh-types' -import type { - RuntimeUploadFileStreamRequest, - StageRuntimeUploadResult -} from '../../shared/runtime-upload-staging-contract' +import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' import type { SearchResult } from '../../shared/code-search-types' import type { FsChangedPayload } from '../../shared/filesystem-entry-types' +import type { + ImportItemResult, + ResolveDroppedPathsResult, + StagedExternalImportSource +} from '../../shared/filesystem-import-result-types' import type { LocalLogTailChangedPayload, LocalLogTailReadArgs, @@ -155,30 +157,10 @@ export const fsApi = { connectionId?: string ensureDir?: boolean } & SshMutationExpectation - ): Promise<{ - results: ( - | { - sourcePath: string - status: 'imported' - destPath: string - kind: 'file' | 'directory' - renamed: boolean - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - )[] - }> => ipcRenderer.invoke('fs:importExternalPaths', args), + ): Promise<{ results: ImportItemResult[] }> => ipcRenderer.invoke('fs:importExternalPaths', args), stageExternalPathsForRuntimeUpload: (args: { sourcePaths: string[] - }): Promise => + }): Promise<{ sources: StagedExternalImportSource[] }> => ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args), uploadExternalFileToRuntime: ( args: RuntimeUploadFileStreamRequest @@ -189,14 +171,8 @@ export const fsApi = { worktreePath: string connectionId?: string } & SshMutationExpectation - ): Promise<{ - resolvedPaths: string[] - skipped: { - sourcePath: string - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - }[] - failed: { sourcePath: string; reason: string }[] - }> => ipcRenderer.invoke('fs:resolveDroppedPathsForAgent', args), + ): Promise => + ipcRenderer.invoke('fs:resolveDroppedPathsForAgent', args), watchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('fs:watchWorktree', args), unwatchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => diff --git a/src/renderer/src/hooks/composer-drop-failure-toast.test.ts b/src/renderer/src/hooks/composer-drop-failure-toast.test.ts new file mode 100644 index 00000000000..ab2945065cb --- /dev/null +++ b/src/renderer/src/hooks/composer-drop-failure-toast.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { toastError } = vi.hoisted(() => ({ + toastError: vi.fn<(title: string, options?: { id?: string; description?: string }) => void>() +})) +vi.mock('sonner', () => ({ toast: { error: toastError } })) + +import { showComposerDropFailureToast } from './composer-drop-failure-toast' +import type { ImportSkipReason } from '../../../shared/filesystem-import-result-types' + +const SKIP_REASON_COPY = [ + ['missing', 'No longer at its original path.'], + ['symlink', 'Symbolic links cannot be attached.'], + ['permission-denied', 'Permission denied.'], + ['unsupported', 'Unsupported file type.'] +] as const satisfies readonly (readonly [ImportSkipReason, string])[] + +function lastToast(): { title: string; id?: string; description?: string } { + const call = toastError.mock.calls.at(-1) + return { + title: String(call?.[0]), + id: call?.[1]?.id, + description: call?.[1]?.description + } +} + +describe('showComposerDropFailureToast', () => { + beforeEach(() => { + toastError.mockClear() + }) + + it('stays neutral about the gesture, and pluralises like its namespace siblings', () => { + showComposerDropFailureToast({ failureCount: 1, total: 1 }) + expect(lastToast().title).toBe('1 of 1 item could not be attached.') + + showComposerDropFailureToast({ failureCount: 2, total: 5 }) + expect(lastToast().title).toBe('2 of 5 items could not be attached.') + }) + + it("turns the import client's skip enum into copy instead of leaking the token", () => { + for (const [reason, expected] of SKIP_REASON_COPY) { + showComposerDropFailureToast({ + failureCount: 1, + total: 3, + commonFailure: { status: 'skipped', reason } + }) + expect(lastToast().description).toBe(expected) + } + }) + + it('passes a free-form failure reason straight through', () => { + showComposerDropFailureToast({ + failureCount: 2, + total: 4, + commonFailure: { status: 'failed', reason: 'EACCES: permission denied' } + }) + expect(lastToast().description).toBe('EACCES: permission denied') + }) + + it('shows no description when nothing explained the failure', () => { + showComposerDropFailureToast({ failureCount: 1, total: 2 }) + expect(lastToast().description).toBeUndefined() + }) + + it('unwraps and clamps a host-minted failure reason before it reaches the row', () => { + showComposerDropFailureToast({ + failureCount: 1, + total: 2, + commonFailure: { + status: 'failed', + reason: + "Error invoking remote method 'runtime:call': Error: EACCES: permission denied\nat Object.upload" + } + }) + expect(lastToast().description).toBe('EACCES: permission denied') + }) + + it('reuses one slot so a second failed drop replaces the first instead of stacking', () => { + showComposerDropFailureToast({ failureCount: 1, total: 2 }) + const first = lastToast().id + showComposerDropFailureToast({ failureCount: 2, total: 3 }) + expect(first).toBeDefined() + expect(lastToast().id).toBe(first) + }) + + it('gives no reason at all when the batch failed for differing reasons', () => { + showComposerDropFailureToast({ failureCount: 3, total: 6 }) + expect(lastToast().title).toBe('3 of 6 items could not be attached.') + expect(lastToast().description).toBeUndefined() + }) +}) diff --git a/src/renderer/src/hooks/composer-drop-failure-toast.ts b/src/renderer/src/hooks/composer-drop-failure-toast.ts new file mode 100644 index 00000000000..a3dde2b7493 --- /dev/null +++ b/src/renderer/src/hooks/composer-drop-failure-toast.ts @@ -0,0 +1,57 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { compactIpcErrorMessage } from '@/lib/ipc-error' +import type { ComposerDropFailure } from './composer-drop-result' +import type { ImportSkipReason } from '../../../shared/filesystem-import-result-types' + +// Own slot, not Source Control's: a drop failure must not erase an unread stage/discard failure. +const DROP_FAILURE_TOAST_ID = 'composer-drop-failure' + +const SKIP_REASON_COPY: Record = { + missing: { + key: 'auto.hooks.useComposerState.attachSkipMissing', + fallback: 'No longer at its original path.' + }, + symlink: { + key: 'auto.hooks.useComposerState.attachSkipSymlink', + fallback: 'Symbolic links cannot be attached.' + }, + 'permission-denied': { + key: 'auto.hooks.useComposerState.attachSkipPermissionDenied', + fallback: 'Permission denied.' + }, + unsupported: { + key: 'auto.hooks.useComposerState.attachSkipUnsupported', + fallback: 'Unsupported file type.' + } +} + +function failureDescription(failure: ComposerDropFailure): string | undefined { + if (failure.status === 'failed') { + return failure.reason ? compactIpcErrorMessage(failure.reason) : undefined + } + const copy = SKIP_REASON_COPY[failure.reason] + return copy ? translate(copy.key, copy.fallback) : undefined +} + +export function showComposerDropFailureToast({ + failureCount, + total, + commonFailure +}: { + failureCount: number + total: number + commonFailure?: ComposerDropFailure +}): void { + toast.error( + translate( + 'auto.hooks.useComposerState.dropPartiallyAttached', + '{{failureCount}} of {{count}} items could not be attached.', + { failureCount, count: total } + ), + { + id: DROP_FAILURE_TOAST_ID, + description: commonFailure ? failureDescription(commonFailure) : undefined + } + ) +} diff --git a/src/renderer/src/hooks/composer-drop-result.test.ts b/src/renderer/src/hooks/composer-drop-result.test.ts new file mode 100644 index 00000000000..7909c3b91e0 --- /dev/null +++ b/src/renderer/src/hooks/composer-drop-result.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { collectComposerDropResult, type ComposerDropItemResult } from './composer-drop-result' + +describe('composer drop result', () => { + it('separates imported files and folders while summarizing failures', () => { + const results: ComposerDropItemResult[] = [ + { status: 'imported', kind: 'file', destPath: '/repo/.orca/drops/file.txt' }, + { status: 'imported', kind: 'directory', destPath: '/repo/.orca/drops/folder' }, + { status: 'skipped', reason: 'permission-denied' }, + { status: 'failed', reason: 'disk full' } + ] + + expect(collectComposerDropResult(results)).toEqual({ + filePaths: ['/repo/.orca/drops/file.txt'], + folderPaths: ['/repo/.orca/drops/folder'], + failureCount: 2, + commonFailure: undefined + }) + }) + + it('keeps a failure only when it explains the whole failed subset', () => { + expect( + collectComposerDropResult([ + { status: 'skipped', reason: 'missing' }, + { status: 'skipped', reason: 'missing' } + ]).commonFailure + ).toEqual({ status: 'skipped', reason: 'missing' }) + + expect( + collectComposerDropResult([ + { status: 'imported', kind: 'file', destPath: '/repo/.orca/drops/file.txt' } + ]).commonFailure + ).toBeUndefined() + }) +}) diff --git a/src/renderer/src/hooks/composer-drop-result.ts b/src/renderer/src/hooks/composer-drop-result.ts new file mode 100644 index 00000000000..dd7334b98db --- /dev/null +++ b/src/renderer/src/hooks/composer-drop-result.ts @@ -0,0 +1,58 @@ +import type { ImportSkipReason } from '../../../shared/filesystem-import-result-types' + +export type ComposerDropItemResult = + | { + status: 'imported' + destPath: string + kind: 'file' | 'directory' + } + | { + status: 'skipped' + reason: ImportSkipReason + } + | { + status: 'failed' + reason?: string + } + +export type ComposerDropFailure = Exclude + +export type ComposerDropResult = { + filePaths: string[] + folderPaths: string[] + failureCount: number + commonFailure?: ComposerDropFailure +} + +function sameFailure(left: ComposerDropFailure, right: ComposerDropFailure): boolean { + return left.status === right.status && left.reason === right.reason +} + +export function collectComposerDropResult( + results: readonly ComposerDropItemResult[] +): ComposerDropResult { + const filePaths: string[] = [] + const folderPaths: string[] = [] + const failures: ComposerDropFailure[] = [] + + for (const result of results) { + if (result.status !== 'imported') { + failures.push(result) + } else if (result.kind === 'directory') { + folderPaths.push(result.destPath) + } else { + filePaths.push(result.destPath) + } + } + + const firstFailure = failures[0] + return { + filePaths, + folderPaths, + failureCount: failures.length, + commonFailure: + firstFailure && failures.every((failure) => sameFailure(firstFailure, failure)) + ? firstFailure + : undefined + } +} diff --git a/src/renderer/src/hooks/composer-drop-upload-result.test.ts b/src/renderer/src/hooks/composer-drop-upload-result.test.ts deleted file mode 100644 index fd7633cd9cf..00000000000 --- a/src/renderer/src/hooks/composer-drop-upload-result.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - collectComposerDropUploadResult, - shouldReportComposerDropUploadFailure, - type ComposerDropUploadImportResult -} from './composer-drop-upload-result' - -describe('composer drop upload result', () => { - it('separates imported files and folders while counting skipped or failed paths', () => { - const results: ComposerDropUploadImportResult[] = [ - { status: 'imported', kind: 'file', destPath: '/repo/.orca/drops/file.txt' }, - { status: 'imported', kind: 'directory', destPath: '/repo/.orca/drops/folder' }, - { status: 'skipped' }, - { status: 'failed' } - ] - - expect(collectComposerDropUploadResult(results)).toEqual({ - filePaths: ['/repo/.orca/drops/file.txt'], - folderPaths: ['/repo/.orca/drops/folder'], - skippedOrFailed: 2 - }) - }) - - it('suppresses failed-upload reporting after a composer loses drop ownership', () => { - const uploadResult = { skippedOrFailed: 1 } - - expect(shouldReportComposerDropUploadFailure(uploadResult, () => true)).toBe(true) - expect(shouldReportComposerDropUploadFailure(uploadResult, () => false)).toBe(false) - expect(shouldReportComposerDropUploadFailure({ skippedOrFailed: 0 }, () => true)).toBe(false) - }) -}) diff --git a/src/renderer/src/hooks/composer-drop-upload-result.ts b/src/renderer/src/hooks/composer-drop-upload-result.ts deleted file mode 100644 index a77cbbc9304..00000000000 --- a/src/renderer/src/hooks/composer-drop-upload-result.ts +++ /dev/null @@ -1,44 +0,0 @@ -export type ComposerDropUploadImportResult = - | { - status: 'imported' - destPath: string - kind: 'file' | 'directory' - } - | { - status: 'skipped' | 'failed' - } - -export type ComposerDropUploadResult = { - filePaths: string[] - folderPaths: string[] - skippedOrFailed: number -} - -export function collectComposerDropUploadResult( - results: readonly ComposerDropUploadImportResult[] -): ComposerDropUploadResult { - const filePaths: string[] = [] - const folderPaths: string[] = [] - let skippedOrFailed = 0 - - for (const result of results) { - if (result.status !== 'imported') { - skippedOrFailed += 1 - continue - } - if (result.kind === 'directory') { - folderPaths.push(result.destPath) - } else { - filePaths.push(result.destPath) - } - } - - return { filePaths, folderPaths, skippedOrFailed } -} - -export function shouldReportComposerDropUploadFailure( - uploadResult: Pick, - canReport: () => boolean -): boolean { - return uploadResult.skippedOrFailed > 0 && canReport() -} diff --git a/src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx b/src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx new file mode 100644 index 00000000000..9b72a3d0dba --- /dev/null +++ b/src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx @@ -0,0 +1,205 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { createRef } from 'react' +import type { Dispatch, SetStateAction } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ toastError: vi.fn(), importExternalPaths: vi.fn() })) + +vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: vi.fn() } })) +vi.mock('@/store', () => ({ + useAppStore: Object.assign(() => undefined, { getState: () => ({}) }) +})) +vi.mock('@/runtime/runtime-file-client', () => ({ + importExternalPathsToRuntime: (...args: unknown[]) => mocks.importExternalPaths(...args) +})) +vi.mock('./composer-drop-listener', () => ({ useComposerDropListener: vi.fn() })) + +import { useAttachmentDropState } from './attachment-drop-state' + +const FAILING_PATHS = new Set(['/drop/bad-1.png', '/drop/bad-2.png', '/drop/bad-3.png']) + +function dropPaths(count: number): string[] { + return [ + ...FAILING_PATHS, + ...Array.from({ length: count - FAILING_PATHS.size }, (_, index) => `/drop/ok-${index}.png`) + ] +} + +function installFsApi(): void { + Object.assign(window, { + api: { + fs: { + authorizeExternalPath: vi.fn(async () => {}), + stat: vi.fn(async ({ filePath }: { filePath: string }) => { + if (FAILING_PATHS.has(filePath)) { + throw new Error( + "Error invoking remote method 'fs:stat': Error: ENOENT: no such file or directory" + ) + } + return { isDirectory: false } + }) + } + } + }) +} + +function renderDropState(setAttachmentPaths: Dispatch>) { + return renderHook(() => + useAttachmentDropState({ + agentPromptRef: { current: '' }, + cancelPromptCaretFrame: () => {}, + connectionId: null, + promptCaretFrameRef: { current: null }, + promptTextareaRef: createRef(), + selectedRepoPath: '/repo', + selectedRepoSettings: null, + setAgentPrompt: () => {}, + setAttachmentPaths + }) + ) +} + +beforeEach(() => { + vi.clearAllMocks() + installFsApi() +}) + +describe('local composer drop failures', () => { + it('reports partially skipped paths in one aggregated toast and still attaches the rest', async () => { + const attached: string[] = [] + const { result } = renderDropState((next) => { + attached.push(...(typeof next === 'function' ? next([]) : next)) + }) + + await act(async () => { + await result.current.applyLocalComposerDrop(dropPaths(12)) + }) + + expect(mocks.toastError).toHaveBeenCalledTimes(1) + const [title, options] = mocks.toastError.mock.calls[0] ?? [] + expect(title).toBe('3 of 12 items could not be attached.') + expect(options.description).toBe('No longer at its original path.') + expect(attached).toHaveLength(9) + expect(attached).not.toContain('/drop/bad-1.png') + }) + + it('stays silent when every dropped path attaches', async () => { + const { result } = renderDropState(() => {}) + + await act(async () => { + await result.current.applyLocalComposerDrop(['/drop/ok-0.png', '/drop/ok-1.png']) + }) + + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('says nothing once the composer that owned the drop is gone', async () => { + const { result } = renderDropState(() => {}) + + await act(async () => { + await result.current.applyLocalComposerDrop(dropPaths(12), () => false) + }) + + expect(mocks.toastError).not.toHaveBeenCalled() + }) +}) + +// Why: the upload branch returns early unless a runtime environment or connection is resolved. +const RUNTIME_SETTINGS = { activeRuntimeEnvironmentId: 'env-1' } + +describe('composer upload failures', () => { + it('aggregates a mixed runtime import into one toast, and withholds a reason that is not shared', async () => { + mocks.importExternalPaths.mockResolvedValue({ + results: [ + { + sourcePath: '/a.png', + status: 'imported', + destPath: '/repo/.orca/drops/a.png', + kind: 'file', + renamed: false + }, + { sourcePath: '/b.png', status: 'skipped', reason: 'permission-denied' }, + { sourcePath: '/c.png', status: 'failed', reason: 'disk full' } + ] + }) + const { result } = renderDropState(() => {}) + + await act(async () => { + await result.current.uploadComposerPaths( + ['/a.png', '/b.png', '/c.png'], + RUNTIME_SETTINGS, + null, + '/repo' + ) + }) + + expect(mocks.toastError).toHaveBeenCalledTimes(1) + const [title, options] = mocks.toastError.mock.calls[0] ?? [] + expect(title).toBe('2 of 3 items could not be attached.') + expect(options.description).toBeUndefined() + }) + + it('stays silent when every uploaded path imports', async () => { + mocks.importExternalPaths.mockResolvedValue({ + results: [ + { + sourcePath: '/a.png', + status: 'imported', + destPath: '/repo/.orca/drops/a.png', + kind: 'file', + renamed: false + } + ] + }) + const { result } = renderDropState(() => {}) + + await act(async () => { + await result.current.uploadComposerPaths(['/a.png'], RUNTIME_SETTINGS, null, '/repo') + }) + + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('does not report after the composer that owned the upload is gone', async () => { + mocks.importExternalPaths.mockResolvedValue({ + results: [{ sourcePath: '/b.png', status: 'skipped', reason: 'missing' }] + }) + const { result } = renderDropState(() => {}) + + await act(async () => { + await result.current.uploadComposerPaths( + ['/b.png'], + RUNTIME_SETTINGS, + null, + '/repo', + () => false + ) + }) + + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('does give the shared reason when every uploaded path failed the same way', async () => { + mocks.importExternalPaths.mockResolvedValue({ + results: [ + { sourcePath: '/b.png', status: 'skipped', reason: 'permission-denied' }, + { sourcePath: '/c.png', status: 'skipped', reason: 'permission-denied' } + ] + }) + const { result } = renderDropState(() => {}) + + await act(async () => { + await result.current.uploadComposerPaths( + ['/b.png', '/c.png'], + RUNTIME_SETTINGS, + null, + '/repo' + ) + }) + + const [, options] = mocks.toastError.mock.calls[0] ?? [] + expect(options.description).toBe('Permission denied.') + }) +}) diff --git a/src/renderer/src/hooks/composer-state/attachment-drop-state.ts b/src/renderer/src/hooks/composer-state/attachment-drop-state.ts index 6dcd27e8af5..ace0e0ba4f7 100644 --- a/src/renderer/src/hooks/composer-state/attachment-drop-state.ts +++ b/src/renderer/src/hooks/composer-state/attachment-drop-state.ts @@ -20,13 +20,27 @@ import { joinPath } from '@/lib/path' import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation' import { useAppStore } from '@/store' import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' +import { readIpcErrorMessage } from '@/lib/ipc-error' +import { showComposerDropFailureToast } from '../composer-drop-failure-toast' import { - collectComposerDropUploadResult, - shouldReportComposerDropUploadFailure -} from '../composer-drop-upload-result' + collectComposerDropResult, + type ComposerDropFailure, + type ComposerDropItemResult +} from '../composer-drop-result' import { applyComposerNativeFileDrop } from '../composer-native-file-drop' import { useComposerDropListener } from './composer-drop-listener' +// Local drops bypass the runtime importer's skip classification. +function localDropFailure(detail: string | undefined): ComposerDropFailure { + if (detail?.startsWith('ENOENT')) { + return { status: 'skipped', reason: 'missing' } + } + if (/^(EACCES|EPERM)/.test(detail ?? '')) { + return { status: 'skipped', reason: 'permission-denied' } + } + return { status: 'failed', reason: detail } +} + export function useAttachmentDropState(input: AttachmentDropStateInput) { const { agentPromptRef, @@ -164,14 +178,13 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { destinationDir, { ensureDestinationDir: true, assertCurrent } ) - const uploadResult = collectComposerDropUploadResult(results) - if (shouldReportComposerDropUploadFailure(uploadResult, canReportFailure)) { - toast.error( - translate( - 'auto.hooks.useComposerState.a9ff236145', - 'Some attachments could not be uploaded.' - ) - ) + const uploadResult = collectComposerDropResult(results) + if (uploadResult.failureCount > 0 && canReportFailure()) { + showComposerDropFailureToast({ + failureCount: uploadResult.failureCount, + total: sourcePaths.length, + commonFailure: uploadResult.commonFailure + }) } return { filePaths: uploadResult.filePaths, folderPaths: uploadResult.folderPaths } }, @@ -199,27 +212,34 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { const applyLocalComposerDrop = useCallback( async (paths: string[], canApply: () => boolean = () => true): Promise => { - const fileAttachments: string[] = [] - const folderPaths: string[] = [] + const results: ComposerDropItemResult[] = [] for (const filePath of paths) { try { await window.api.fs.authorizeExternalPath({ targetPath: filePath }) const stat = await window.api.fs.stat({ filePath }) - if (stat.isDirectory) { - folderPaths.push(filePath) - } else { - fileAttachments.push(filePath) - } - } catch { - // Skip paths we cannot authorize or stat. + results.push({ + status: 'imported', + destPath: filePath, + kind: stat.isDirectory ? 'directory' : 'file' + }) + } catch (error) { + results.push(localDropFailure(readIpcErrorMessage(error))) } } if (!canApply()) { return } - addComposerAttachments(fileAttachments) - insertComposerFolderPaths(folderPaths) + const dropResult = collectComposerDropResult(results) + addComposerAttachments(dropResult.filePaths) + insertComposerFolderPaths(dropResult.folderPaths) + if (dropResult.failureCount > 0) { + showComposerDropFailureToast({ + failureCount: dropResult.failureCount, + total: paths.length, + commonFailure: dropResult.commonFailure + }) + } }, [addComposerAttachments, insertComposerFolderPaths] ) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 8707f0d0adb..8fb5d19446c 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2535,6 +2535,14 @@ } }, "hooks": { + "useComposerState": { + "attachSkipMissing": "No longer at its original path.", + "attachSkipPermissionDenied": "Permission denied.", + "attachSkipSymlink": "Symbolic links cannot be attached.", + "attachSkipUnsupported": "Unsupported file type.", + "dropPartiallyAttached_one": "{{failureCount}} of {{count}} item could not be attached.", + "dropPartiallyAttached_other": "{{failureCount}} of {{count}} items could not be attached." + }, "useIpcEvents": { "60428567b4": "Local terminal reveal is unavailable while a remote runtime is active", "f6300deb8b": "New Browser Tab" diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 696e7f5ed7f..4d30c7941a7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -990,14 +990,20 @@ "useComposerState": { "7eb3f44ff7": "Selected agent is disabled. Choose an enabled agent before creating.", "b2ead86962": "Failed to resolve PR base.", - "a9ff236145": "Some attachments could not be uploaded.", "3db83fc58a": "No project path is available on this host for attachments.", "ba6cb77082": "Failed to connect to project.", "chooseOrAddProjectBeforeWorkspace": "Choose or add a project before creating a workspace.", "folderWorkspaceCreateFailedTitle": "Folder workspace creation failed", "folderWorkspaceCreateFailedMessage": "The folder workspace could not be created. Check the error details above, then try again.", "setupAgentStartupPolicySaveFailed": "Failed to save setup startup behavior.", - "5f3d2c8a1b": "Failed to resolve MR base." + "5f3d2c8a1b": "Failed to resolve MR base.", + "dropPartiallyAttached": "{{failureCount}} of {{count}} items could not be attached.", + "dropPartiallyAttached_one": "{{failureCount}} of {{count}} item could not be attached.", + "dropPartiallyAttached_other": "{{failureCount}} of {{count}} items could not be attached.", + "attachSkipMissing": "No longer at its original path.", + "attachSkipSymlink": "Symbolic links cannot be attached.", + "attachSkipPermissionDenied": "Permission denied.", + "attachSkipUnsupported": "Unsupported file type." }, "useGlobalFileDrop": { "38c9f034ff": "Failed to upload dropped files.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 61c3f812f5f..2a47a73502d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -713,7 +713,6 @@ "useComposerState": { "7eb3f44ff7": "El agente seleccionado está deshabilitado. Elige un agente habilitado antes de crear.", "b2ead86962": "No se pudo resolver la base del PR.", - "a9ff236145": "Algunos archivos adjuntos no se pudieron cargar.", "3db83fc58a": "No hay ninguna ruta de proyecto remoto disponible para los archivos adjuntos.", "ba6cb77082": "No se pudo conectar al proyecto.", "chooseOrAddProjectBeforeWorkspace": "Elige o agrega un proyecto antes de crear un espacio de trabajo.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index c21e08e7a5a..3a56fce87a6 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -835,7 +835,6 @@ "useComposerState": { "7eb3f44ff7": "L'agent sélectionné est désactivé. Choisissez un agent activé avant de créer.", "b2ead86962": "Échec de la résolution de la base de la PR.", - "a9ff236145": "Certaines pièces jointes n'ont pas pu être envoyées.", "3db83fc58a": "Aucun chemin de projet n'est disponible sur cet hôte pour les pièces jointes.", "ba6cb77082": "Échec de la connexion au projet.", "chooseOrAddProjectBeforeWorkspace": "Choisissez ou ajoutez un projet avant de créer un espace de travail.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 422e2b39fbc..696135a7669 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -713,7 +713,6 @@ "useComposerState": { "7eb3f44ff7": "選択した Agent は無効です。作成する前に、有効な Agent を選択してください。", "b2ead86962": "PR ベースを解決できませんでした。", - "a9ff236145": "一部の添付ファイルをアップロードできませんでした。", "3db83fc58a": "このホスト上に、添付に使用できるプロジェクトパスがありません。", "ba6cb77082": "プロジェクトへの接続に失敗しました。", "chooseOrAddProjectBeforeWorkspace": "ワークスペースを作成する前に、プロジェクトを選択または追加してください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 45d2d30a225..5fd1e6addb8 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -716,7 +716,6 @@ "useComposerState": { "7eb3f44ff7": "선택한 agent가 비활성화되었습니다. 생성하기 전에 활성화된 agent를 선택하세요.", "b2ead86962": "PR 기반을 해결하지 못했습니다.", - "a9ff236145": "일부 첨부파일을 업로드할 수 없습니다.", "3db83fc58a": "이 호스트에 첨부 파일에 사용할 수 있는 프로젝트 경로가 없습니다.", "ba6cb77082": "프로젝트에 연결하지 못했습니다.", "chooseOrAddProjectBeforeWorkspace": "워크스페이스를 만들기 전에 프로젝트를 선택하거나 추가하세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f64188d7201..125876be1a0 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -716,7 +716,6 @@ "useComposerState": { "7eb3f44ff7": "所选智能体已禁用。创建之前选择启用的智能体。", "b2ead86962": "无法解析 PR 基础引用。", - "a9ff236145": "部分附件无法上传。", "3db83fc58a": "没有可用于附件的远程项目路径。", "ba6cb77082": "无法连接到项目。", "chooseOrAddProjectBeforeWorkspace": "创建工作区前,请选择或添加项目。", diff --git a/src/renderer/src/lib/ipc-error.test.ts b/src/renderer/src/lib/ipc-error.test.ts index 52d789f0027..cf7ba9d6817 100644 --- a/src/renderer/src/lib/ipc-error.test.ts +++ b/src/renderer/src/lib/ipc-error.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { extractIpcErrorMessage, readIpcErrorDetail, readIpcErrorMessage } from './ipc-error' +import { + compactIpcErrorMessage, + extractIpcErrorMessage, + readIpcErrorDetail, + readIpcErrorMessage +} from './ipc-error' describe('readIpcErrorMessage', () => { it('strips the Electron invoke wrapper Electron adds to a rejected handler', () => { @@ -45,6 +50,16 @@ describe('readIpcErrorMessage', () => { }) }) +describe('compactIpcErrorMessage', () => { + it('normalizes string error fields without manufacturing an Error', () => { + expect( + compactIpcErrorMessage( + "Error invoking remote method 'files:import': Error: permission denied\nstack" + ) + ).toBe('permission denied') + }) +}) + describe('extractIpcErrorMessage', () => { it('unwraps the same way readIpcErrorMessage does', () => { expect( diff --git a/src/renderer/src/lib/ipc-error.ts b/src/renderer/src/lib/ipc-error.ts index d9ef8a364eb..020bc7e9629 100644 --- a/src/renderer/src/lib/ipc-error.ts +++ b/src/renderer/src/lib/ipc-error.ts @@ -2,19 +2,20 @@ const IPC_INVOKE_PREFIX = /Error invoking remote method '[^']*':\s*(?:Error:\s*)?/ const IPC_HANDLER_PREFIX = /Error occurred in handler for '[^']*':\s*(?:Error:\s*)?/ +function unwrapIpcErrorMessage(message: string): string | undefined { + const detail = message.replace(IPC_INVOKE_PREFIX, '').replace(IPC_HANDLER_PREFIX, '').trim() + return detail || undefined +} + +export function compactIpcErrorMessage(message: string): string | undefined { + return unwrapIpcErrorMessage(message)?.split('\n')[0]?.trim() || undefined +} export function readIpcErrorDetail(error: unknown): string | undefined { - if (!(error instanceof Error)) { - return undefined - } - const message = error.message - .replace(IPC_INVOKE_PREFIX, '') - .replace(IPC_HANDLER_PREFIX, '') - .trim() - return message || undefined + return error instanceof Error ? unwrapIpcErrorMessage(error.message) : undefined } export function readIpcErrorMessage(error: unknown): string | undefined { - return readIpcErrorDetail(error)?.split('\n')[0]?.trim() || undefined + return error instanceof Error ? compactIpcErrorMessage(error.message) : undefined } // Preserve the legacy contract: wrapped errors are compact, while plain errors retain detail. diff --git a/src/renderer/src/runtime/runtime-file-import-client.ts b/src/renderer/src/runtime/runtime-file-import-client.ts index cfbec0ac146..2aebfbcfa8c 100644 --- a/src/renderer/src/runtime/runtime-file-import-client.ts +++ b/src/renderer/src/runtime/runtime-file-import-client.ts @@ -1,4 +1,5 @@ import { basename, joinPath } from '@/lib/path' +import type { ImportItemResult } from '../../../shared/filesystem-import-result-types' import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' import type { RuntimeFileOperationArgs } from './runtime-file-client-types' import { captureRuntimeEnvironmentRequestRevision } from './runtime-environment-revision' @@ -21,31 +22,12 @@ import { import { getActiveRuntimeTarget } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' -type RuntimeImportResult = - | { - sourcePath: string - status: 'imported' - destPath: string - kind: 'file' | 'directory' - renamed: boolean - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - export async function importExternalPathsToRuntime( context: RuntimeFileOperationArgs, sourcePaths: string[], destinationDir: string, options?: { ensureDestinationDir?: boolean; assertCurrent?: () => void } -): Promise<{ results: RuntimeImportResult[] }> { +): Promise<{ results: ImportItemResult[] }> { const target = getActiveRuntimeTarget(context.settings) if (target.kind !== 'environment' || !context.worktreeId || !context.worktreePath) { return window.api.fs.importExternalPaths( @@ -89,7 +71,7 @@ export async function importExternalPathsToRuntime( importSession.assertCurrent() const staged = await window.api.fs.stageExternalPathsForRuntimeUpload({ sourcePaths }) importSession.assertCurrent() - const results: RuntimeImportResult[] = [] + const results: ImportItemResult[] = [] const reservedNames = new Set() await ensureRuntimeDirectory(context, destinationDir, importSession) diff --git a/src/main/ipc/filesystem-import-result-types.ts b/src/shared/filesystem-import-result-types.ts similarity index 81% rename from src/main/ipc/filesystem-import-result-types.ts rename to src/shared/filesystem-import-result-types.ts index 1d3e1a3fcad..206570e9a33 100644 --- a/src/main/ipc/filesystem-import-result-types.ts +++ b/src/shared/filesystem-import-result-types.ts @@ -1,7 +1,7 @@ import type { StagedRuntimeUploadEntry, StagedRuntimeUploadSource -} from '../../shared/runtime-upload-staging-contract' +} from './runtime-upload-staging-contract' export type ImportSkipReason = 'missing' | 'symlink' | 'permission-denied' | 'unsupported' @@ -11,8 +11,6 @@ export type ResolveDroppedPathsResult = { failed: { sourcePath: string; reason: string }[] } -// ─── External Import Types ────────────────────────────────────────── - export type ImportItemResult = | { sourcePath: string