diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx index 5b719911c01..6c0fe15b4ab 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx @@ -207,7 +207,9 @@ describe('useNativeChatComposerAttachments', () => { act(() => probe.root.unmount()) }) - it('rejects a mixed queued batch after the target becomes remote', async () => { + // Ownership is per path: the target-owned drop still lands, the client-local + // paste is refused, and the refusal is reported rather than hidden. + it('keeps the owned half of a mixed queued batch after the target becomes remote', async () => { let composing = true const probe = await renderProbe('pty-1', false, { isComposing: () => composing }) @@ -221,6 +223,20 @@ describe('useNativeChatComposerAttachments', () => { composing = false act(() => probe.latest().flushPendingAttachments()) + expect(probe.draft()).toBe('@/remote/trusted.txt ') + expect(probe.notice()).toBe('Local attachments are not available for remote sessions.') + act(() => probe.root.unmount()) + }) + + it('refuses a wholly client-local queued batch on a remote target', async () => { + let composing = true + const probe = await renderProbe('pty-1', false, { isComposing: () => composing }) + + act(() => probe.latest().attachResolvedPaths(['/local/untrusted.txt'])) + runtimeTarget.remote = true + composing = false + act(() => probe.latest().flushPendingAttachments()) + expect(probe.draft()).toBe('') expect(probe.notice()).toBe('Local attachments are not available for remote sessions.') act(() => probe.root.unmount()) diff --git a/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts index 991acc5b212..ff67badab1a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts @@ -80,28 +80,41 @@ export function useNativeChatResolvedPathAttachments({ const applyResolvedPaths = useCallback( (resolvedPaths: ResolvedAttachmentPath[], focus: boolean, preserveNotice = false) => { - const targetOwnership = resolvedPaths.map(({ targetOwnerIsCurrent }) => - targetOwnerIsCurrent ? targetOwnerIsCurrent() : null - ) - if (targetOwnership.some((isCurrent) => isCurrent === false)) { + if (resolvedPaths.length === 0) { + return + } + if (resolvedPaths.some(({ targetOwnerIsCurrent }) => targetOwnerIsCurrent?.() === false)) { setNotice(nativeChatWorkspaceAttachmentMismatchNotice()) return } - if (attachmentTargetBlocked(targetOwnership.every((isCurrent) => isCurrent === true))) { + // Ownership is per path, so the verdict is too: a queued batch can mix a + // workspace drop the target owns with a client-local paste it does not, + // and one verdict for the batch would refuse the drop the user can make. + const owned = resolvedPaths.filter(({ targetOwnerIsCurrent }) => targetOwnerIsCurrent) + const clientLocal = resolvedPaths.filter(({ targetOwnerIsCurrent }) => !targetOwnerIsCurrent) + const ownedBlocked = owned.length > 0 && attachmentTargetBlocked(true) + const clientLocalBlocked = clientLocal.length > 0 && attachmentTargetBlocked(false) + const attachable = [ + ...(ownedBlocked ? [] : owned), + ...(clientLocalBlocked ? [] : clientLocal) + ] + if (attachable.length === 0) { noteAttachmentTargetBlocked() return } - const imagePaths = resolvedPaths.filter(({ path }) => isNativeChatImageAttachmentPath(path)) - const filePaths = resolvedPaths + const imagePaths = attachable.filter(({ path }) => isNativeChatImageAttachmentPath(path)) + const filePaths = attachable .filter(({ path }) => !isNativeChatImageAttachmentPath(path)) .map(({ path }) => path) // Images ride along on submit so chips and the TUI input cannot diverge. appendImageAttachments(imagePaths.map(({ path, connectionId }) => ({ path, connectionId }))) insertFileReferences(filePaths) - if (!preserveNotice) { + if (ownedBlocked || clientLocalBlocked) { + noteAttachmentTargetBlocked() + } else if (!preserveNotice) { setNotice(null) } - if (focus && resolvedPaths.length > 0) { + if (focus) { requestAnimationFrame(() => textareaRef.current?.focus()) } },