From c351f5756fda2252afdb363f8e7f0e2f065ca0e2 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sun, 13 Sep 2026 15:07:41 -0700
Subject: [PATCH] fix(native-chat): ask which workspace the composer serves now
The IME-flush ownership check compared the workspace id captured when the
drop happened against the same captured value, so for a structured pane the
comparison could only ever hold. The live protection came from the host and
owner checks beside it; this one asked nothing.
Read the id through a ref so the check means what it reads as. A pane whose
structured target moves between the drop and the composition settling now
refuses the queued path instead of attaching it.
---
...chat-composer-workspace-file-drop.test.tsx | 21 ++++++++++++++++++-
.../use-native-chat-workspace-file-drop.ts | 14 ++++++++++---
2 files changed, 31 insertions(+), 4 deletions(-)
diff --git a/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx
index 54ed8f51d9b..97b6f846cf3 100644
--- a/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx
+++ b/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx
@@ -111,6 +111,8 @@ type ProbeProps = {
disabled?: boolean
initialDraft?: string
structured?: boolean
+ /** Overrides only the structured target, leaving the pane's scope key alone. */
+ structuredWorkspaceId?: string
workspaceId?: string
}
@@ -121,6 +123,7 @@ function ComposerProbe({
disabled = false,
initialDraft = '',
structured = true,
+ structuredWorkspaceId,
workspaceId = 'worktree-1'
}: ProbeProps): React.JSX.Element {
const [draft, setDraft] = useState(initialDraft)
@@ -143,7 +146,7 @@ function ComposerProbe({
})
const workspaceFileDropHandlers = useNativeChatWorkspaceFileDrop({
terminalTabId: 'terminal-tab-1',
- structuredWorktreeId: structured ? workspaceId : undefined,
+ structuredWorktreeId: structured ? (structuredWorkspaceId ?? workspaceId) : undefined,
disabled,
attachResolvedPaths: attachments.attachResolvedPaths,
setNotice
@@ -244,6 +247,7 @@ describe('native chat workspace file drops', () => {
testState.ownerSshGeneration = 4
testState.ownerWorktreePath = '/remote/repo'
testState.targetIsRemoteRuntime = false
+ testState.store.tabsByWorktree = { 'worktree-1': [{ id: 'terminal-tab-1' }] }
latestInput = null
bubbledDrop.mockReset()
})
@@ -425,6 +429,21 @@ describe('native chat workspace file drops', () => {
expect(screen.getByText('Files can only be attached to their source workspace.')).toBeTruthy()
})
+ // The queued check must ask which workspace this composer serves NOW. Comparing
+ // a captured id against itself would pass no matter where the pane ended up.
+ it('rejects an IME-queued path when the pane changes workspace before settling', () => {
+ const view = render()
+ const input = editor()
+ fireEvent.compositionStart(input)
+ dispatchDragEvent('drop', input, internalTransfer(['/repo/a.ts']))
+
+ view.rerender()
+ fireEvent.compositionEnd(input, { data: '' })
+
+ expect(screen.getByTestId('draft').textContent).toBe('preedit')
+ expect(screen.getByText('Files can only be attached to their source workspace.')).toBeTruthy()
+ })
+
it('rejects only the exact unresolved-owner sentinel', () => {
testState.executionHostId = 'runtime:unresolved-owner'
const view = render()
diff --git a/src/renderer/src/components/native-chat/use-native-chat-workspace-file-drop.ts b/src/renderer/src/components/native-chat/use-native-chat-workspace-file-drop.ts
index 638c2fa55d1..ee03aa6849c 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-workspace-file-drop.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-workspace-file-drop.ts
@@ -1,4 +1,4 @@
-import { useCallback, type DragEventHandler } from 'react'
+import { useCallback, useLayoutEffect, useRef, type DragEventHandler } from 'react'
import { useAppStore } from '@/store'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import {
@@ -67,6 +67,14 @@ export function useNativeChatWorkspaceFileDrop({
structuredWorktreeId,
terminalTabId
}: Args): WorkspaceFileDropHandlers {
+ // The IME-flush check runs against a closure captured at drop time. Reading
+ // the prop through a ref keeps "is this still my workspace?" a real question
+ // rather than a comparison of one captured value against itself.
+ const structuredWorktreeIdRef = useRef(structuredWorktreeId)
+ useLayoutEffect(() => {
+ structuredWorktreeIdRef.current = structuredWorktreeId
+ }, [structuredWorktreeId])
+
const onDragOverCapture = useCallback>(
(event) => {
if (!hasWorkspaceFileDragType(event.dataTransfer)) {
@@ -130,7 +138,7 @@ export function useNativeChatWorkspaceFileDrop({
const targetOwnerIsCurrent = (): boolean => {
const currentState = useAppStore.getState()
const currentWorkspaceId =
- structuredWorktreeId ??
+ structuredWorktreeIdRef.current ??
findTerminalTabWorktreeId(currentState.tabsByWorktree, terminalTabId)
if (currentWorkspaceId !== source.workspaceId) {
return false
@@ -139,7 +147,7 @@ export function useNativeChatWorkspaceFileDrop({
const currentOwner = resolveNativeChatAttachmentOwnerForWorktree(
currentState,
currentWorkspaceId,
- structuredWorktreeId ? undefined : terminalTabId
+ structuredWorktreeIdRef.current ? undefined : terminalTabId
)
return (
isResolvedWorkspaceFileDragExecutionHost(currentHostId) &&