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.
This commit is contained in:
Brennan Benson
2026-09-13 15:07:41 -07:00
parent a0156e8f04
commit c351f5756f
2 changed files with 31 additions and 4 deletions
@@ -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(<ComposerProbe initialDraft="preedit" />)
const input = editor()
fireEvent.compositionStart(input)
dispatchDragEvent('drop', input, internalTransfer(['/repo/a.ts']))
view.rerender(<ComposerProbe initialDraft="preedit" structuredWorkspaceId="worktree-2" />)
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(<ComposerProbe />)
@@ -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<DragEventHandler<HTMLDivElement>>(
(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) &&