Fix native chat drop ownership across session surfaces

This commit is contained in:
Brennan Benson
2026-09-13 22:47:56 -07:00
parent ea23ccbbee
commit 4f5d25c321
5 changed files with 92 additions and 13 deletions
@@ -28,12 +28,13 @@ export function useNativeChatPaneFileDropClaim(claim: NativeChatPaneDropClaim):
useLayoutEffect(() => {
claimRef.current = claim
})
const { scopeKey } = claim
const { scopeKey, disabled } = claim
const registration = useMemo<NativeChatPaneDropRegistration>(
() => ({ getClaim: () => claimRef.current, scopeKey }),
[scopeKey]
)
useLayoutEffect(() => register?.(registration), [register, registration])
// A guard transition ends the current hover before the next paint.
useLayoutEffect(() => register?.(registration), [disabled, register, registration])
}
export function NativeChatPaneFileDropSurface({
@@ -55,15 +56,13 @@ export function NativeChatPaneFileDropSurface({
},
[]
)
const registrationRef = useRef(registration)
registrationRef.current = registration
const handlers = useMemo(
() =>
makeNativeChatPaneFileDropHandlers({
getClaim: () => registrationRef.current?.getClaim() ?? null,
getClaim: () => registration?.getClaim() ?? null,
setDragActive: setIsDragActive
}),
[]
[registration]
)
// An OS drag never reaches React: the preload drop route consumes that event
// before it leaves `document`. Its end is the only signal the overlay gets.
@@ -3,15 +3,21 @@ import { NativeChatStructuredSession } from './NativeChatStructuredSession'
import { NativeChatResolvedView } from './NativeChatResolvedView'
import { useNativeChatStatusEntry } from './use-native-chat-status-entry'
import type { NativeChatViewProps } from './native-chat-view-types'
import { NativeChatPaneFileDropSurface } from './NativeChatPaneFileDropSurface'
export type { NativeChatViewProps } from './native-chat-view-types'
/** Resolves an agent terminal into its native conversation and composer UI. */
export default function NativeChatView(props: NativeChatViewProps): React.JSX.Element {
if (props.mode === 'structured') {
return <NativeChatStructuredSession key={props.sessionId} {...props} />
}
return <NativeChatBridgeView {...props} />
return (
<NativeChatPaneFileDropSurface className="relative flex h-full min-h-0 min-w-0 w-full">
{props.mode === 'structured' ? (
<NativeChatStructuredSession key={props.sessionId} {...props} />
) : (
<NativeChatBridgeView {...props} />
)}
</NativeChatPaneFileDropSurface>
)
}
function NativeChatBridgeView({
@@ -152,6 +152,26 @@ describe('NativeChatPaneFileDropSurface', () => {
expect(onDropCapture).not.toHaveBeenCalled()
})
it('clears the active invitation when the composer becomes guarded', () => {
const pane = (disabled: boolean) => (
<NativeChatPaneFileDropSurface className="pane">
<ClaimingComposer disabled={disabled} />
</NativeChatPaneFileDropSurface>
)
const { container, rerender } = render(pane(false))
fireDrag(screen.getByTestId('composer'), 'dragover', workspaceDrag())
expect(container.querySelector(OVERLAY)).not.toBeNull()
rerender(pane(true))
expect(container.querySelector(OVERLAY)).toBeNull()
expect(container.querySelector('.pane')?.getAttribute('data-composer-scope-key')).toBe('pane:1')
rerender(pane(false))
expect(container.querySelector(OVERLAY)).toBeNull()
fireDrag(screen.getByTestId('composer'), 'dragover', workspaceDrag())
expect(container.querySelector(OVERLAY)).not.toBeNull()
})
it('keeps the overlay up while the cursor crosses children, and drops it on exit', () => {
const { transcript, container } = renderPane(<ClaimingComposer />)
fireDrag(transcript, 'dragover', workspaceDrag())
@@ -0,0 +1,55 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
import NativeChatView from './NativeChatView'
import { useNativeChatPaneFileDropClaim } from './NativeChatPaneFileDropSurface'
const drop = vi.hoisted(() => ({ onDrop: vi.fn() }))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
vi.mock('./NativeChatSessionGate', () => ({ NativeChatSessionGate: () => null }))
vi.mock('./NativeChatResolvedView', () => ({ NativeChatResolvedView: () => null }))
vi.mock('./use-native-chat-status-entry', () => ({ useNativeChatStatusEntry: vi.fn() }))
vi.mock('./NativeChatStructuredSession', () => ({
NativeChatStructuredSession: () => {
useNativeChatPaneFileDropClaim({
scopeKey: 'structured:session',
disabled: false,
onDragOverCapture: (event) => event.preventDefault(),
onDropCapture: drop.onDrop
})
return <div data-testid="transcript">Conversation above the composer</div>
}
}))
afterEach(cleanup)
it('owns pane drops for standalone structured sessions without a terminal portal', () => {
const { container } = render(
<NativeChatView
mode="structured"
tabId="structured-tab"
sessionId="session"
agent="codex"
target={{ kind: 'local' }}
isVisible
isFocusedGroup
/>
)
const transcript = screen.getByTestId('transcript')
const dataTransfer = { types: [WORKSPACE_FILE_PATH_MIME] }
fireEvent.dragOver(transcript, { dataTransfer })
expect(container.querySelector('[data-native-chat-drop-overlay]')).not.toBeNull()
expect(
transcript
.closest('[data-native-file-drop-target="composer"]')
?.getAttribute('data-composer-scope-key')
).toBe('structured:session')
fireEvent.drop(transcript, { dataTransfer })
expect(drop.onDrop).toHaveBeenCalledTimes(1)
expect(container.querySelector('[data-native-chat-drop-overlay]')).toBeNull()
})
@@ -1,6 +1,5 @@
import { createPortal } from 'react-dom'
import NativeChatView from '../native-chat/NativeChatView'
import { NativeChatPaneFileDropSurface } from '../native-chat/NativeChatPaneFileDropSurface'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { canContinueAgentSessionInNewSession } from './terminal-agent-session-continuation'
import type { TerminalPaneController } from './use-terminal-pane-controller'
@@ -69,7 +68,7 @@ export function TerminalPaneNativeChatPortal({
}
return createPortal(
<NativeChatPaneFileDropSurface className="native-chat-pane-shell absolute inset-0 z-10 flex min-h-0 min-w-0 bg-background">
<div className="native-chat-pane-shell absolute inset-0 z-10 flex min-h-0 min-w-0 bg-background">
{structuredSessionId && structuredChatAgent ? (
<NativeChatView
mode="structured"
@@ -96,7 +95,7 @@ export function TerminalPaneNativeChatPortal({
contextMenuActions={contextMenuActions}
/>
)}
</NativeChatPaneFileDropSurface>,
</div>,
chatPane.container,
`native-chat-${tabId}-${chatPane.leafId}`
)