diff --git a/src/renderer/src/components/native-chat/NativeChatPaneFileDropSurface.tsx b/src/renderer/src/components/native-chat/NativeChatPaneFileDropSurface.tsx index 5081a74a052..8f129f86c57 100644 --- a/src/renderer/src/components/native-chat/NativeChatPaneFileDropSurface.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPaneFileDropSurface.tsx @@ -28,12 +28,13 @@ export function useNativeChatPaneFileDropClaim(claim: NativeChatPaneDropClaim): useLayoutEffect(() => { claimRef.current = claim }) - const { scopeKey } = claim + const { scopeKey, disabled } = claim const registration = useMemo( () => ({ 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. diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index 15c04823542..d17de41a854 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -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 - } - return + return ( + + {props.mode === 'structured' ? ( + + ) : ( + + )} + + ) } function NativeChatBridgeView({ diff --git a/src/renderer/src/components/native-chat/native-chat-pane-file-drop.test.tsx b/src/renderer/src/components/native-chat/native-chat-pane-file-drop.test.tsx index 02e7d39f05a..59bc32abdda 100644 --- a/src/renderer/src/components/native-chat/native-chat-pane-file-drop.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-pane-file-drop.test.tsx @@ -152,6 +152,26 @@ describe('NativeChatPaneFileDropSurface', () => { expect(onDropCapture).not.toHaveBeenCalled() }) + it('clears the active invitation when the composer becomes guarded', () => { + const pane = (disabled: boolean) => ( + + + + ) + 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() fireDrag(transcript, 'dragover', workspaceDrag()) diff --git a/src/renderer/src/components/native-chat/native-chat-view-file-drop.test.tsx b/src/renderer/src/components/native-chat/native-chat-view-file-drop.test.tsx new file mode 100644 index 00000000000..d3f10a4dcde --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-view-file-drop.test.tsx @@ -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
Conversation above the composer
+ } +})) + +afterEach(cleanup) + +it('owns pane drops for standalone structured sessions without a terminal portal', () => { + const { container } = render( + + ) + 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() +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx index 1cebc980c31..fd21e73ff68 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx @@ -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( - +
{structuredSessionId && structuredChatAgent ? ( )} - , +
, chatPane.container, `native-chat-${tabId}-${chatPane.leafId}` )