fix(native-chat): decide attachment ownership per path, not per batch

A queued batch can mix sources — a workspace drop the target host owns and a
client-local paste it cannot read — because IME composition holds both until it
settles. Collapsing the batch to one verdict refused the whole thing on a remote
target, including the drop the user was entitled to make.

The verdict now follows the path it belongs to: owned paths attach, client-local
ones are refused, and the refusal is reported rather than dropped. A stale owner
still refuses everything, since that means the target moved under all of them.
Also guards the empty-batch case, which previously read as "every path owned".
This commit is contained in:
Brennan Benson
2026-09-13 13:51:42 -07:00
parent e371e99985
commit cd39265a7f
2 changed files with 39 additions and 10 deletions
@@ -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())
@@ -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())
}
},