mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 08:02:38 +00:00
feat(native-chat): show a drop target on the whole chat pane
Dropping a file into a chat only worked if you hit the input box, and nothing on screen said so. A drag aimed at the transcript fell through to the terminal behind the chat, which pastes the paths into the hidden TUI. The chat pane shell is now the drop surface. While a drag carrying files is over it, the pane dims behind a card naming what the drop will do; the composer's existing attach logic — workspace, execution-host and SSH-owner checks included — runs unchanged from the wider element. Two routes reach the composer and they are widened differently: - an in-app drag is claimed in the renderer, so the pane calls the composer's handlers through a claim the composer publishes to the surface around it. - an OS drag is delivered by the preload drop route, which consumes the event at `document` before React sees it. The pane widens that route by publishing the composer's scope key as the nearest drop marker, and learns the drag ended from a document-level listener rather than a React drop. The surface lives in the chat portal because that is the one place wrapping both the bridge and structured panes, so neither pane root grows a second copy of this wiring. No composer mounted (a question card owns the input region) means no marker and no overlay, so that drag stays the terminal's. A guarded composer still answers for the drag — that refusal is what keeps it out of the terminal — but the pane does not invite a drop it will refuse.
This commit is contained in:
@@ -174,10 +174,11 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
resolvePendingImageAttachment,
|
||||
dropPendingImageAttachment
|
||||
} = attachments
|
||||
const workspaceFileDropHandlers = useNativeChatWorkspaceFileDrop({
|
||||
useNativeChatWorkspaceFileDrop({
|
||||
terminalTabId,
|
||||
structuredWorktreeId: structuredTransport?.worktreeId,
|
||||
disabled,
|
||||
paneKey,
|
||||
attachResolvedPaths,
|
||||
setNotice
|
||||
})
|
||||
@@ -421,7 +422,6 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
}}
|
||||
onRemoveImageAttachment={(id) => removeImageAttachment(id)}
|
||||
onAttach={pickAttachment}
|
||||
workspaceFileDropHandlers={workspaceFileDropHandlers}
|
||||
onDictationToggle={toggleDictation}
|
||||
onDictationHoldStart={startHoldDictation}
|
||||
onDictationHoldEnd={stopHoldDictation}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { NativeChatPromptEditor } from './NativeChatPromptEditor'
|
||||
import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
import type {
|
||||
ClipboardEventHandler,
|
||||
DragEventHandler,
|
||||
KeyboardEventHandler,
|
||||
RefObject
|
||||
} from 'react'
|
||||
import type { ClipboardEventHandler, KeyboardEventHandler, RefObject } from 'react'
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { ImageOff } from 'lucide-react'
|
||||
import type { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
|
||||
@@ -53,10 +48,6 @@ export type NativeChatComposerFieldProps = {
|
||||
onAcceptMention: () => void
|
||||
onRemoveImageAttachment: (id: string) => void
|
||||
onAttach: () => void
|
||||
workspaceFileDropHandlers?: {
|
||||
onDragOverCapture: DragEventHandler<HTMLDivElement>
|
||||
onDropCapture: DragEventHandler<HTMLDivElement>
|
||||
}
|
||||
onDictationToggle: () => void
|
||||
onDictationHoldStart: () => void
|
||||
onDictationHoldEnd: () => void
|
||||
@@ -129,7 +120,6 @@ export function NativeChatComposerField({
|
||||
onAcceptMention,
|
||||
onRemoveImageAttachment,
|
||||
onAttach,
|
||||
workspaceFileDropHandlers,
|
||||
onDictationToggle,
|
||||
onDictationHoldStart,
|
||||
onDictationHoldEnd,
|
||||
@@ -195,7 +185,6 @@ export function NativeChatComposerField({
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
{...workspaceFileDropHandlers}
|
||||
data-native-file-drop-target={NATIVE_FILE_DROP_TARGET.composer}
|
||||
data-composer-scope-key={composerScopeKey}
|
||||
className={cn(
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { createContext, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Paperclip } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop'
|
||||
import {
|
||||
makeNativeChatPaneFileDropHandlers,
|
||||
type NativeChatPaneDropClaim
|
||||
} from './native-chat-pane-file-drop'
|
||||
|
||||
/** A mounted composer's live drop claim. The getter is what the surface reads at
|
||||
* event time, so a guarded composer answers for the drag in front of it. */
|
||||
export type NativeChatPaneDropRegistration = {
|
||||
getClaim: () => NativeChatPaneDropClaim
|
||||
scopeKey: string
|
||||
}
|
||||
|
||||
type RegisterPaneDropClaim = (registration: NativeChatPaneDropRegistration) => () => void
|
||||
|
||||
const NativeChatPaneFileDropContext = createContext<RegisterPaneDropClaim | null>(null)
|
||||
|
||||
/**
|
||||
* Publishes the composer's drop claim to the pane around it, so the whole chat
|
||||
* pane — not just the input box — is the target a file can be dropped on.
|
||||
*/
|
||||
export function useNativeChatPaneFileDropClaim(claim: NativeChatPaneDropClaim): void {
|
||||
const register = useContext(NativeChatPaneFileDropContext)
|
||||
const claimRef = useRef(claim)
|
||||
useLayoutEffect(() => {
|
||||
claimRef.current = claim
|
||||
})
|
||||
const { scopeKey } = claim
|
||||
const registration = useMemo<NativeChatPaneDropRegistration>(
|
||||
() => ({ getClaim: () => claimRef.current, scopeKey }),
|
||||
[scopeKey]
|
||||
)
|
||||
useLayoutEffect(() => register?.(registration), [register, registration])
|
||||
}
|
||||
|
||||
export function NativeChatPaneFileDropSurface({
|
||||
className,
|
||||
children
|
||||
}: {
|
||||
className: string
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const [registration, setRegistration] = useState<NativeChatPaneDropRegistration | null>(null)
|
||||
const [isDragActive, setIsDragActive] = useState(false)
|
||||
const register = useMemo<RegisterPaneDropClaim>(
|
||||
() => (next) => {
|
||||
setRegistration(next)
|
||||
return () => {
|
||||
setRegistration((current) => (current === next ? null : current))
|
||||
setIsDragActive(false)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
const registrationRef = useRef(registration)
|
||||
registrationRef.current = registration
|
||||
const handlers = useMemo(
|
||||
() =>
|
||||
makeNativeChatPaneFileDropHandlers({
|
||||
getClaim: () => registrationRef.current?.getClaim() ?? null,
|
||||
setDragActive: setIsDragActive
|
||||
}),
|
||||
[]
|
||||
)
|
||||
// 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.
|
||||
useLayoutEffect(() => {
|
||||
if (!isDragActive) {
|
||||
return
|
||||
}
|
||||
const clear = (): void => setIsDragActive(false)
|
||||
document.addEventListener('drop', clear, true)
|
||||
document.addEventListener('dragend', clear, true)
|
||||
return () => {
|
||||
document.removeEventListener('drop', clear, true)
|
||||
document.removeEventListener('dragend', clear, true)
|
||||
}
|
||||
}, [isDragActive])
|
||||
|
||||
return (
|
||||
<NativeChatPaneFileDropContext.Provider value={register}>
|
||||
<div
|
||||
className={className}
|
||||
// Why: the preload route reads the nearest marker, so publishing the
|
||||
// composer's scope here is what widens an OS drop to the whole pane.
|
||||
data-native-file-drop-target={registration ? NATIVE_FILE_DROP_TARGET.composer : undefined}
|
||||
data-composer-scope-key={registration?.scopeKey}
|
||||
{...handlers}
|
||||
>
|
||||
{children}
|
||||
{isDragActive ? <NativeChatPaneFileDropOverlay /> : null}
|
||||
</div>
|
||||
</NativeChatPaneFileDropContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function NativeChatPaneFileDropOverlay(): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
data-native-chat-drop-overlay="true"
|
||||
className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-background/80"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1 rounded-xl border border-dashed border-foreground/30 bg-card px-8 py-5 text-center shadow-floating">
|
||||
<span className="mb-1 flex size-9 items-center justify-center rounded-full bg-foreground/10">
|
||||
<Paperclip className="size-4.5" />
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{translate('components.native-chat.drop.title', 'Drop to attach to this chat')}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'components.native-chat.drop.subtitle',
|
||||
'Files are added to your message as paths the agent can read.'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+47
-43
@@ -151,6 +151,7 @@ function ComposerProbe({
|
||||
terminalTabId: 'terminal-tab-1',
|
||||
structuredWorktreeId: structured ? (structuredWorkspaceId ?? workspaceId) : undefined,
|
||||
disabled,
|
||||
paneKey: `pane:${workspaceId}`,
|
||||
attachResolvedPaths: attachments.attachResolvedPaths,
|
||||
setNotice
|
||||
})
|
||||
@@ -160,49 +161,52 @@ function ComposerProbe({
|
||||
|
||||
return (
|
||||
<div onDrop={bubbledDrop}>
|
||||
<NativeChatComposerField
|
||||
composerScopeKey={`pane:${workspaceId}`}
|
||||
textareaRef={inputRef}
|
||||
draft={draft}
|
||||
disabled={disabled}
|
||||
hasPty
|
||||
canSend={!disabled}
|
||||
autocomplete={{ mode: 'none' }}
|
||||
activeSuggestion={0}
|
||||
notice={notice}
|
||||
imageAttachments={attachments.imageAttachments}
|
||||
sendButtonDisabled={false}
|
||||
isWorking={false}
|
||||
attachDisabled={disabled}
|
||||
dictationDisabled
|
||||
isDictating={false}
|
||||
isDictationHoldMode={false}
|
||||
imeEnterGesture={imeEnterGesture}
|
||||
onDraftChange={(value, input) => {
|
||||
setDraft(value)
|
||||
setCaret(input.selectionStart ?? value.length)
|
||||
}}
|
||||
onTextareaSelect={(input) => setCaret(input.selectionStart ?? input.value.length)}
|
||||
onKeyDown={() => {}}
|
||||
onImeSettled={(input) => {
|
||||
setDraft(input.value)
|
||||
attachments.flushPendingAttachments()
|
||||
}}
|
||||
onPaste={() => {}}
|
||||
pickerListboxId="picker"
|
||||
onChoosePickerItem={() => {}}
|
||||
onRetrySkills={() => {}}
|
||||
onAcceptMention={() => {}}
|
||||
onRemoveImageAttachment={attachments.removeImageAttachment}
|
||||
onAttach={() => {}}
|
||||
workspaceFileDropHandlers={workspaceFileDropHandlers}
|
||||
onDictationToggle={() => {}}
|
||||
onDictationHoldStart={() => {}}
|
||||
onDictationHoldEnd={() => {}}
|
||||
onSend={() => {}}
|
||||
sessionOptionsSurface={null}
|
||||
sessionOptionsSnapshot={[]}
|
||||
/>
|
||||
{/* The pane around the composer mounts these in production; here they sit
|
||||
on a bare wrapper so the drop logic is exercised on its own. */}
|
||||
<div {...workspaceFileDropHandlers}>
|
||||
<NativeChatComposerField
|
||||
composerScopeKey={`pane:${workspaceId}`}
|
||||
textareaRef={inputRef}
|
||||
draft={draft}
|
||||
disabled={disabled}
|
||||
hasPty
|
||||
canSend={!disabled}
|
||||
autocomplete={{ mode: 'none' }}
|
||||
activeSuggestion={0}
|
||||
notice={notice}
|
||||
imageAttachments={attachments.imageAttachments}
|
||||
sendButtonDisabled={false}
|
||||
isWorking={false}
|
||||
attachDisabled={disabled}
|
||||
dictationDisabled
|
||||
isDictating={false}
|
||||
isDictationHoldMode={false}
|
||||
imeEnterGesture={imeEnterGesture}
|
||||
onDraftChange={(value, input) => {
|
||||
setDraft(value)
|
||||
setCaret(input.selectionStart ?? value.length)
|
||||
}}
|
||||
onTextareaSelect={(input) => setCaret(input.selectionStart ?? input.value.length)}
|
||||
onKeyDown={() => {}}
|
||||
onImeSettled={(input) => {
|
||||
setDraft(input.value)
|
||||
attachments.flushPendingAttachments()
|
||||
}}
|
||||
onPaste={() => {}}
|
||||
pickerListboxId="picker"
|
||||
onChoosePickerItem={() => {}}
|
||||
onRetrySkills={() => {}}
|
||||
onAcceptMention={() => {}}
|
||||
onRemoveImageAttachment={attachments.removeImageAttachment}
|
||||
onAttach={() => {}}
|
||||
onDictationToggle={() => {}}
|
||||
onDictationHoldStart={() => {}}
|
||||
onDictationHoldEnd={() => {}}
|
||||
onSend={() => {}}
|
||||
sessionOptionsSurface={null}
|
||||
sessionOptionsSnapshot={[]}
|
||||
/>
|
||||
</div>
|
||||
<output data-testid="draft">{draft}</output>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
|
||||
import {
|
||||
NativeChatPaneFileDropSurface,
|
||||
useNativeChatPaneFileDropClaim
|
||||
} from './NativeChatPaneFileDropSurface'
|
||||
import { nativeChatPaneDragKind } from './native-chat-pane-file-drop'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
|
||||
const OVERLAY = '[data-native-chat-drop-overlay="true"]'
|
||||
const IGNORE_DRAG = (): void => {}
|
||||
|
||||
class DropDataTransfer {
|
||||
dropEffect = 'none'
|
||||
effectAllowed = 'all'
|
||||
private readonly data = new Map<string, string>()
|
||||
get types(): string[] {
|
||||
return [...this.data.keys()]
|
||||
}
|
||||
setData(type: string, value: string): void {
|
||||
this.data.set(type, value)
|
||||
}
|
||||
getData(type: string): string {
|
||||
return this.data.get(type) ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceDrag(): DropDataTransfer {
|
||||
const transfer = new DropDataTransfer()
|
||||
transfer.setData(WORKSPACE_FILE_PATH_MIME, '/repo/a.ts')
|
||||
return transfer
|
||||
}
|
||||
|
||||
function osDrag(): DropDataTransfer {
|
||||
const transfer = new DropDataTransfer()
|
||||
transfer.setData('Files', '')
|
||||
return transfer
|
||||
}
|
||||
|
||||
/** Mounts the composer's claim the way the real composer hook does. */
|
||||
function ClaimingComposer({
|
||||
disabled = false,
|
||||
onDragOverCapture = IGNORE_DRAG,
|
||||
onDropCapture = IGNORE_DRAG
|
||||
}: {
|
||||
disabled?: boolean
|
||||
onDragOverCapture?: (event: React.DragEvent<HTMLDivElement>) => void
|
||||
onDropCapture?: (event: React.DragEvent<HTMLDivElement>) => void
|
||||
}) {
|
||||
useNativeChatPaneFileDropClaim({
|
||||
scopeKey: 'pane:1',
|
||||
disabled,
|
||||
onDragOverCapture,
|
||||
onDropCapture
|
||||
})
|
||||
return <div data-testid="composer" />
|
||||
}
|
||||
|
||||
function renderPane(composer: React.ReactNode) {
|
||||
const result = render(
|
||||
<div data-testid="terminal-behind">
|
||||
<NativeChatPaneFileDropSurface className="pane">
|
||||
<div data-testid="transcript">transcript</div>
|
||||
{composer}
|
||||
</NativeChatPaneFileDropSurface>
|
||||
</div>
|
||||
)
|
||||
return { ...result, transcript: screen.getByTestId('transcript') }
|
||||
}
|
||||
|
||||
function fireDrag(
|
||||
target: HTMLElement,
|
||||
type: 'dragenter' | 'dragover' | 'dragleave' | 'drop',
|
||||
dataTransfer: DropDataTransfer,
|
||||
relatedTarget: EventTarget | null = null
|
||||
): void {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'dataTransfer', { value: dataTransfer })
|
||||
Object.defineProperty(event, 'relatedTarget', { value: relatedTarget })
|
||||
act(() => {
|
||||
target.dispatchEvent(event)
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('nativeChatPaneDragKind', () => {
|
||||
it('separates an in-app file drag from an OS file drag', () => {
|
||||
expect(nativeChatPaneDragKind(workspaceDrag())).toBe('workspace')
|
||||
expect(nativeChatPaneDragKind(osDrag())).toBe('os')
|
||||
expect(nativeChatPaneDragKind(new DropDataTransfer())).toBeNull()
|
||||
})
|
||||
|
||||
it('reads an OS-looking drag that carries in-app paths as the in-app drag', () => {
|
||||
const transfer = osDrag()
|
||||
transfer.setData(WORKSPACE_FILE_PATH_MIME, '/repo/a.ts')
|
||||
expect(nativeChatPaneDragKind(transfer)).toBe('workspace')
|
||||
})
|
||||
})
|
||||
|
||||
describe('NativeChatPaneFileDropSurface', () => {
|
||||
it('routes a drop on the transcript to the composer that claimed the pane', () => {
|
||||
const onDropCapture = vi.fn()
|
||||
const { transcript, container } = renderPane(<ClaimingComposer onDropCapture={onDropCapture} />)
|
||||
|
||||
fireDrag(transcript, 'dragover', workspaceDrag())
|
||||
expect(container.querySelector(OVERLAY)).not.toBeNull()
|
||||
|
||||
fireDrag(transcript, 'drop', workspaceDrag())
|
||||
expect(onDropCapture).toHaveBeenCalledTimes(1)
|
||||
expect(container.querySelector(OVERLAY)).toBeNull()
|
||||
})
|
||||
|
||||
it('publishes the claiming composer as the OS drop route target', () => {
|
||||
const { container } = renderPane(<ClaimingComposer />)
|
||||
const surface = container.querySelector('.pane')
|
||||
expect(surface?.getAttribute('data-native-file-drop-target')).toBe('composer')
|
||||
expect(surface?.getAttribute('data-composer-scope-key')).toBe('pane:1')
|
||||
})
|
||||
|
||||
it('leaves the pane to the terminal behind it while no composer is mounted', () => {
|
||||
const { transcript, container } = renderPane(null)
|
||||
fireDrag(transcript, 'dragover', workspaceDrag())
|
||||
expect(container.querySelector(OVERLAY)).toBeNull()
|
||||
expect(container.querySelector('.pane')?.hasAttribute('data-native-file-drop-target')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('does not invite a drop the guarded composer will refuse', () => {
|
||||
const onDragOverCapture = vi.fn()
|
||||
const { transcript, container } = renderPane(
|
||||
<ClaimingComposer disabled onDragOverCapture={onDragOverCapture} />
|
||||
)
|
||||
fireDrag(transcript, 'dragover', workspaceDrag())
|
||||
expect(container.querySelector(OVERLAY)).toBeNull()
|
||||
// The composer still answers for the drag: that refusal is what keeps it
|
||||
// out of the terminal behind the chat.
|
||||
expect(onDragOverCapture).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows the overlay for an OS drag without claiming its drop', () => {
|
||||
const onDropCapture = vi.fn()
|
||||
const { transcript, container } = renderPane(<ClaimingComposer onDropCapture={onDropCapture} />)
|
||||
fireDrag(transcript, 'dragover', osDrag())
|
||||
expect(container.querySelector(OVERLAY)).not.toBeNull()
|
||||
fireDrag(transcript, 'drop', osDrag())
|
||||
expect(onDropCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the overlay up while the cursor crosses children, and drops it on exit', () => {
|
||||
const { transcript, container } = renderPane(<ClaimingComposer />)
|
||||
fireDrag(transcript, 'dragover', workspaceDrag())
|
||||
|
||||
fireDrag(transcript, 'dragleave', workspaceDrag(), screen.getByTestId('composer'))
|
||||
expect(container.querySelector(OVERLAY)).not.toBeNull()
|
||||
|
||||
fireDrag(transcript, 'dragleave', workspaceDrag(), screen.getByTestId('terminal-behind'))
|
||||
expect(container.querySelector(OVERLAY)).toBeNull()
|
||||
})
|
||||
|
||||
it('clears an OS drag overlay from the document drop the preload route consumes', () => {
|
||||
const { transcript, container } = renderPane(<ClaimingComposer />)
|
||||
fireDrag(transcript, 'dragover', osDrag())
|
||||
expect(container.querySelector(OVERLAY)).not.toBeNull()
|
||||
|
||||
// The preload listener stops this event at `document`, so the surface never
|
||||
// sees it as a React drop.
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event('drop', { bubbles: false }))
|
||||
})
|
||||
expect(container.querySelector(OVERLAY)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { hasWorkspaceFileDragType } from '@/lib/workspace-file-drag'
|
||||
import { hasNativeFileDragTypes } from '../../../../shared/native-file-drop'
|
||||
|
||||
/** What a drag hovering the chat pane is carrying.
|
||||
* `workspace`: an in-app file drag, attached by the composer's own handlers.
|
||||
* `os`: a Finder/Explorer drag, delivered by the preload drop route instead. */
|
||||
export type NativeChatPaneDragKind = 'os' | 'workspace'
|
||||
|
||||
export type NativeChatPaneDragEvent = {
|
||||
currentTarget: { contains: (node: Node | null) => boolean }
|
||||
dataTransfer: Pick<DataTransfer, 'types'>
|
||||
relatedTarget: EventTarget | null
|
||||
}
|
||||
|
||||
/** The composer's drop claim, published while a composer is mounted in the pane. */
|
||||
export type NativeChatPaneDropClaim = {
|
||||
/** Composer identity the preload drop route addresses (`data-composer-scope-key`). */
|
||||
scopeKey: string
|
||||
/** A guarded composer refuses the drop, so the pane must not invite one. */
|
||||
disabled: boolean
|
||||
onDragOverCapture: (event: React.DragEvent<HTMLDivElement>) => void
|
||||
onDropCapture: (event: React.DragEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
export function nativeChatPaneDragKind(
|
||||
dataTransfer: Pick<DataTransfer, 'types'> | null
|
||||
): NativeChatPaneDragKind | null {
|
||||
if (!dataTransfer) {
|
||||
return null
|
||||
}
|
||||
if (hasWorkspaceFileDragType(dataTransfer)) {
|
||||
return 'workspace'
|
||||
}
|
||||
return hasNativeFileDragTypes(dataTransfer.types) ? 'os' : null
|
||||
}
|
||||
|
||||
/** True while the cursor only crossed between children of the drop surface —
|
||||
* the leave that follows every child boundary must not end the drag state. */
|
||||
export function movedWithinDropSurface(event: NativeChatPaneDragEvent): boolean {
|
||||
const enteredNode = event.relatedTarget
|
||||
return enteredNode instanceof Node && event.currentTarget.contains(enteredNode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides what the pane does with a drag, given what the mounted composer will
|
||||
* accept. Only a workspace drag is claimed here: an OS drag reaches the composer
|
||||
* through the preload route, which already consumed the drop event by the time
|
||||
* React would see it, so the pane's part of that route is the overlay alone.
|
||||
*/
|
||||
export function makeNativeChatPaneFileDropHandlers(host: {
|
||||
getClaim: () => NativeChatPaneDropClaim | null
|
||||
setDragActive: (active: boolean) => void
|
||||
}): {
|
||||
onDragEnterCapture: (event: React.DragEvent<HTMLDivElement>) => void
|
||||
onDragLeaveCapture: (event: React.DragEvent<HTMLDivElement>) => void
|
||||
onDragOverCapture: (event: React.DragEvent<HTMLDivElement>) => void
|
||||
onDropCapture: (event: React.DragEvent<HTMLDivElement>) => void
|
||||
} {
|
||||
const showsDropTarget = (event: NativeChatPaneDragEvent): boolean => {
|
||||
const kind = nativeChatPaneDragKind(event.dataTransfer)
|
||||
if (kind === null) {
|
||||
return false
|
||||
}
|
||||
const claim = host.getClaim()
|
||||
// No composer (a question card owns the input region) means no attachment
|
||||
// target, so the drag stays the terminal's the way it is today.
|
||||
return claim !== null && !claim.disabled
|
||||
}
|
||||
|
||||
return {
|
||||
onDragEnterCapture(event) {
|
||||
if (showsDropTarget(event)) {
|
||||
host.setDragActive(true)
|
||||
}
|
||||
},
|
||||
onDragOverCapture(event) {
|
||||
if (showsDropTarget(event)) {
|
||||
host.setDragActive(true)
|
||||
}
|
||||
if (nativeChatPaneDragKind(event.dataTransfer) === 'workspace') {
|
||||
host.getClaim()?.onDragOverCapture(event)
|
||||
}
|
||||
},
|
||||
onDragLeaveCapture(event) {
|
||||
if (nativeChatPaneDragKind(event.dataTransfer) === null || movedWithinDropSurface(event)) {
|
||||
return
|
||||
}
|
||||
host.setDragActive(false)
|
||||
},
|
||||
onDropCapture(event) {
|
||||
host.setDragActive(false)
|
||||
if (nativeChatPaneDragKind(event.dataTransfer) === 'workspace') {
|
||||
host.getClaim()?.onDropCapture(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
nativeChatWorkspaceAttachmentMismatchNotice,
|
||||
type NativeChatResolvedPathOptions
|
||||
} from './native-chat-resolved-path-ownership'
|
||||
import { useNativeChatPaneFileDropClaim } from './NativeChatPaneFileDropSurface'
|
||||
|
||||
type WorkspaceFileDropHandlers = {
|
||||
onDragOverCapture: DragEventHandler<HTMLDivElement>
|
||||
@@ -31,6 +32,9 @@ type Args = {
|
||||
options?: NativeChatResolvedPathOptions
|
||||
) => void
|
||||
disabled: boolean
|
||||
/** Composer identity the preload drop route addresses; published to the pane
|
||||
* so an OS drop anywhere in it resolves to this composer. */
|
||||
paneKey: string
|
||||
setNotice: (notice: string | null) => void
|
||||
structuredWorktreeId?: string
|
||||
terminalTabId: string
|
||||
@@ -63,6 +67,7 @@ function setDropEffect(dataTransfer: DataTransfer, effect: 'copy' | 'none'): voi
|
||||
export function useNativeChatWorkspaceFileDrop({
|
||||
attachResolvedPaths,
|
||||
disabled,
|
||||
paneKey,
|
||||
setNotice,
|
||||
structuredWorktreeId,
|
||||
terminalTabId
|
||||
@@ -163,5 +168,14 @@ export function useNativeChatWorkspaceFileDrop({
|
||||
[attachResolvedPaths, disabled, setNotice, structuredWorktreeId, terminalTabId]
|
||||
)
|
||||
|
||||
// The pane around the composer is the drop surface; these handlers run from
|
||||
// there so the whole chat, not just the input box, accepts a file.
|
||||
useNativeChatPaneFileDropClaim({
|
||||
scopeKey: paneKey,
|
||||
disabled,
|
||||
onDragOverCapture,
|
||||
onDropCapture
|
||||
})
|
||||
|
||||
return { onDragOverCapture, onDropCapture }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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'
|
||||
@@ -68,7 +69,7 @@ export function TerminalPaneNativeChatPortal({
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="native-chat-pane-shell absolute inset-0 z-10 flex min-h-0 min-w-0 bg-background">
|
||||
<NativeChatPaneFileDropSurface className="native-chat-pane-shell absolute inset-0 z-10 flex min-h-0 min-w-0 bg-background">
|
||||
{structuredSessionId && structuredChatAgent ? (
|
||||
<NativeChatView
|
||||
mode="structured"
|
||||
@@ -95,7 +96,7 @@ export function TerminalPaneNativeChatPortal({
|
||||
contextMenuActions={contextMenuActions}
|
||||
/>
|
||||
)}
|
||||
</div>,
|
||||
</NativeChatPaneFileDropSurface>,
|
||||
chatPane.container,
|
||||
`native-chat-${tabId}-${chatPane.leafId}`
|
||||
)
|
||||
|
||||
@@ -17304,6 +17304,10 @@
|
||||
"conversationCommand": {
|
||||
"pendingWork": "Wait for pending work and messages to finish before using this command.",
|
||||
"unconfirmed": "Conversation operation was not confirmed."
|
||||
},
|
||||
"drop": {
|
||||
"title": "Drop to attach to this chat",
|
||||
"subtitle": "Files are added to your message as paths the agent can read."
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
|
||||
Reference in New Issue
Block a user