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) &&