fix(composer): clarify failed attachment drops (#20704)

* refactor(renderer): give the IPC error reader a clamped and an unclamped shape

* fix(composer): name the attachments a drop could not add, in one toast

* fix(composer, source-control): use one stable failure toast slot

- Replace per-worktree toast IDs with single slot that replaces on each failure
- Remove destructive retry actions; discard must confirm in dialog
- Consolidate filesystem import types to shared location
- Add compactIpcErrorMessage for string error handling

* refactor: centralize filesystem import types and clarify failure naming

Move import result types from main/ipc to shared layer so they're available
across preload and renderer. Rename uniformFailure → commonFailure and
skippedOrFailed → failureCount for clarity. Simplify preload/API type
definitions by reusing shared types directly instead of duplicating inlined
union shapes.

* Reuse single toast slot for composer drop failures

Multiple drop failures now replace the previous toast instead of
stacking, preventing notification clutter. Uses a dedicated toast ID
separate from Source Control's stage/discard notifications.
This commit is contained in:
Jinjing
2026-09-14 15:22:05 -07:00
committed by GitHub
parent 767b7c14f1
commit b8554f1c59
26 changed files with 558 additions and 215 deletions
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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,
@@ -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 {}
@@ -1,11 +1,7 @@
{
"capturedAt": "2026-09-14T11:25:01.730Z",
"platform": "darwin",
"command": [
"bun",
"tests/tools/omp-native-title-capture.mjs",
"<read-only-omp-checkout>"
],
"command": ["bun", "tests/tools/omp-native-title-capture.mjs", "<read-only-omp-checkout>"],
"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.",
+9 -34
View File
@@ -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<StageRuntimeUploadResult>
}) => 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<ResolveDroppedPathsResult>
watchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise<void>
unwatchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise<void>
onFsChanged: (callback: (payload: FsChangedPayload) => void) => () => void
+10 -34
View File
@@ -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<StageRuntimeUploadResult> =>
}): 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<ResolveDroppedPathsResult> =>
ipcRenderer.invoke('fs:resolveDroppedPathsForAgent', args),
watchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise<void> =>
ipcRenderer.invoke('fs:watchWorktree', args),
unwatchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise<void> =>
@@ -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()
})
})
@@ -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<ImportSkipReason, { key: string; fallback: string }> = {
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
}
)
}
@@ -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()
})
})
@@ -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<ComposerDropItemResult, { status: 'imported' }>
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
}
}
@@ -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)
})
})
@@ -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<ComposerDropUploadResult, 'skippedOrFailed'>,
canReport: () => boolean
): boolean {
return uploadResult.skippedOrFailed > 0 && canReport()
}
@@ -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<SetStateAction<string[]>>) {
return renderHook(() =>
useAttachmentDropState({
agentPromptRef: { current: '' },
cancelPromptCaretFrame: () => {},
connectionId: null,
promptCaretFrameRef: { current: null },
promptTextareaRef: createRef<HTMLTextAreaElement>(),
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.')
})
})
@@ -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<void> => {
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]
)
+8
View File
@@ -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"
+8 -2
View File
@@ -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.",
-1
View File
@@ -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.",
-1
View File
@@ -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.",
-1
View File
@@ -713,7 +713,6 @@
"useComposerState": {
"7eb3f44ff7": "選択した Agent は無効です。作成する前に、有効な Agent を選択してください。",
"b2ead86962": "PR ベースを解決できませんでした。",
"a9ff236145": "一部の添付ファイルをアップロードできませんでした。",
"3db83fc58a": "このホスト上に、添付に使用できるプロジェクトパスがありません。",
"ba6cb77082": "プロジェクトへの接続に失敗しました。",
"chooseOrAddProjectBeforeWorkspace": "ワークスペースを作成する前に、プロジェクトを選択または追加してください。",
-1
View File
@@ -716,7 +716,6 @@
"useComposerState": {
"7eb3f44ff7": "선택한 agent가 비활성화되었습니다. 생성하기 전에 활성화된 agent를 선택하세요.",
"b2ead86962": "PR 기반을 해결하지 못했습니다.",
"a9ff236145": "일부 첨부파일을 업로드할 수 없습니다.",
"3db83fc58a": "이 호스트에 첨부 파일에 사용할 수 있는 프로젝트 경로가 없습니다.",
"ba6cb77082": "프로젝트에 연결하지 못했습니다.",
"chooseOrAddProjectBeforeWorkspace": "워크스페이스를 만들기 전에 프로젝트를 선택하거나 추가하세요.",
-1
View File
@@ -716,7 +716,6 @@
"useComposerState": {
"7eb3f44ff7": "所选智能体已禁用。创建之前选择启用的智能体。",
"b2ead86962": "无法解析 PR 基础引用。",
"a9ff236145": "部分附件无法上传。",
"3db83fc58a": "没有可用于附件的远程项目路径。",
"ba6cb77082": "无法连接到项目。",
"chooseOrAddProjectBeforeWorkspace": "创建工作区前,请选择或添加项目。",
+16 -1
View File
@@ -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(
+10 -9
View File
@@ -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.
@@ -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<string>()
await ensureRuntimeDirectory(context, destinationDir, importSession)
@@ -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