feat(native-chat): support file drag and drop (#20494)

* feat(native-chat): support workspace file drops

* fix(native-chat): report OS file drops that attach nothing

#15782 is a silent failure on the Finder route, and that route still
swallowed every way it could fail:

- the preload handler returned with no feedback when the OS handed us
  file items `webUtils.getPathForFile` could read no path from (promised
  or virtual files). It now sends the existing `rejected` payload with a
  new `unresolved-paths` reason, which the global drop toast names.
- the composer's external-attach path dropped the batch with no notice
  when every path failed authorization, when an upload came back empty,
  and (new in this branch) when the owner changed mid-flight. Each exit
  now sets a notice; only a disabled composer stays quiet, because it has
  no notice surface.

Also stops `resolveNativeChatAttachmentOwnerForWorktree` throwing out of a
drop/IME handler when an SSH connection's generation is gone mid-attach —
that is an unknown owner, which the resolver already models as
`not-ready`.

* refactor(native-chat): one owner-identity check for composer attachments

The branch had two near-identical "is this still the same owner" helpers,
one per attach route, and they disagreed: the workspace-drop copy ignored
the SSH connection generation, so a reconnect between the drop and the IME
flush read as the same owner and the path landed on a new connection.

Collapses both onto one predicate in the pure ownership module (the
store/toast-free seam both routes already depend on), which compares the
full SSH expectation and never treats `not-ready` as a match.

* perf(file-explorer): resolve drag ownership at dragstart, not per render

The virtualized row list resolved the selection's source execution host on
every render — the virtualizer re-renders on every scroll frame, so a large
multi-selection paid a full projection scan plus a route allocation per
selected path per frame, and per visible row on top of that. Only
`onDragStart` ever read the result.

Rows now receive a resolver they call with the paths they are about to
drag. The three copies of the "stamp only if both halves resolve" guard
(explorer row, both combined-diff row shapes) collapse into one helper next
to the writer.

* fix(native-chat): refuse a guarded composer drop visibly

The drop handlers claimed the drag (preventDefault + stopPropagation) before
checking `disabled`, so a guarded composer told the browser it accepted the
drop, left the copy cursor up, and then did nothing — the same silent swallow
this branch exists to remove.

Dragover now answers `none` when the composer is guarded, so the cursor refuses
and no drop event follows. It still claims the event either way: the composer
sits inside the terminal surface, which accepts the same drag and would paste
the paths into the shell instead.

Drops `stopImmediatePropagation`. The capture-phase `stopPropagation` already
keeps the event off the editor below, so the stronger form only risked
suppressing unrelated listeners on the React root.

The fake DataTransfer in the test now starts at a dropEffect we never write, so
asserting `none` or `copy` proves the handler set it.

* fix(native-chat): decide attachment ownership per path, not per batch

A queued batch can mix sources — a workspace drop the target host owns and a
client-local paste it cannot read — because IME composition holds both until it
settles. Collapsing the batch to one verdict refused the whole thing on a remote
target, including the drop the user was entitled to make.

The verdict now follows the path it belongs to: owned paths attach, client-local
ones are refused, and the refusal is reported rather than dropped. A stale owner
still refuses everything, since that means the target moved under all of them.
Also guards the empty-batch case, which previously read as "every path owned".

* refactor(combined-diff): resolve drag ownership from the live workspace

The combined diff captured an execution host into the open-file record at tab
open and drilled it through three components to reach the row. That host was
never persisted, so after a restart every drag from a restored diff was refused
until the tab was reopened, and the capture failure was swallowed into an
undefined source with no trace.

Rows now resolve the owner the same way the source-control rows already do, from
the workspace the diff belongs to at the moment of the drag. That deletes the
prop drilling, the store capture and its bare catch, and leaves one way to
answer "who owns these paths" for every live listing.

The file explorer keeps its per-node owner: its tree is a cache that can still be
showing a previous host's listing, which is exactly what that field records.

* revert(file-explorer): drop the workspace-id tree reset

Resetting and reloading the tree when the workspace id changes at an unchanged
path is not needed for the drag source to be correct. The tree already records
the workspace whose root listing it committed, so a cache left over from a
previous workspace stamps that workspace and the composer refuses the drop —
the intended answer, reached without touching the reset rule.

That rule clears selection, the name filter and undo history, which is more
file-explorer behaviour change than this feature asked for.

* test(native-chat): stop the external-attach mock hiding new notices

The hook's test replaced the whole attachment-owner module with a hand-written
stub, so the two notices added alongside the owner-change guards resolved to
undefined. Calling them threw inside the async attach loop — an unhandled
rejection, which leaves every test in the file reported as passing while the run
as a whole fails. CI caught it; a local run reporting only pass/fail counts does
not.

The mock now spreads the real module, so a notice added later cannot go missing
from it, and both owner-change tests assert the string a user would read instead
of only asserting that nothing attached.

* test(native-chat): guard the last-path owner change on a one-file drop

The owner flipping while the final path is authorizing has no next loop
iteration to catch it, so the post-loop check is all that stands between a
single-file drop and a path attached to a host that no longer owns it — and a
one-file drop is the ordinary shape. No test covered that exit.

Removing the post-loop check now turns this red; before it, only the
multi-path exit was guarded.

* fix(native-chat): keep a mixed attachment batch in attach order

applyResolvedPaths partitioned a queued batch into a target-owned half and
a client-local half and concatenated them. An IME-delayed batch that mixed
a workspace drop with a paste made earlier in the same composition was
therefore inserted owned-first, so the dropped reference jumped ahead of
the pasted one in the draft.

Filter against the two verdicts in place instead. Membership is unchanged,
the order the user attached in survives, and the two intermediate arrays go
away.

* fix(file-explorer): name the owner of a dragged path whose row is hidden

A multi-selection outlives the rows that showed it. Nothing prunes
selectedPaths when a directory collapses, when the name filter narrows, or
when dotfiles are hidden, and the drag still carries every selected path.
Drag-source resolution read those owners from the row projection, which is
built from visible rows only, so one hidden path collapsed the whole drag to
an unstamped one and the composer refused it as coming from another
workspace.

The owner was never unknowable — the dir cache the projection is built from
still records which host listed that path. Fall back to it when the path has
no visible row. A path in neither (a name-filter synthetic node for a
directory that was never listed) still fails closed.

* 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.

* fix(native-chat): ask which workspace an external attach lands on

The post-await ownership gate resolved the owner through the render closure, so
it re-asked the workspace the attach started in and compared the answer with
itself. A tab moved to another workspace mid-authorization passed the gate, and
the paths landed in a composer that no longer served that workspace.

Read the pane through a ref and compare the workspace identity as well as the
owner: two workspaces can both report a local owner, so the owner alone cannot
tell them apart.

* test(native-chat): read the real notice on a workspace drop

The drop tests hand-built their attachment-upload mock and hand-copied the
not-ready wording into it, so the assertion tracked the copy rather than the
string a user reads: rewording the real notice left all 15 tests green.

Spread the real module and override only the owner resolver, matching the two
sibling test files in this directory. Rewording the notice now fails the test.

* docs(native-chat): restore the hook's doc comment to the hook

The workspace comparison landed between the doc block and the function it
describes, leaving the comment attached to a type alias.

* test(native-chat): cover the upload window for a moved pane

The workspace-currency gate guards two windows and only the authorize loop was
covered. The upload window is the longer one: the paths go to the worktree the
attach captured, so a pane that moved workspaces meanwhile must not receive
remote paths living under the workspace it left.

* test(native-chat): pin the two untested attachment refusals

Refusing an already-blocked target at the drop rather than queueing it had no
test: queued paths that can never attach still spend the pending budget, and the
next legitimate drop is then turned away for being one too many.

Also pins the immediate already-false ownership verdict. Today's only caller
settles ownership synchronously so it cannot arrive false, but the hook exports
this entry point and the fallback is not a refusal — a false verdict is not
"owned", so a remote target blames client-local attachments for an ownership
failure. Verified: removing the branch reports the wrong notice.

* docs(native-chat): say which rule the ownership refusal follows

The per-path comment sat directly above the batch-wide ownership refusal while
describing the blocked-target logic below it, so the refusal read as a
contradiction of the line under it rather than as the file's stated rule.

Name the rule at the refusal: a failed ownership verdict refuses the whole
completion, the same way the pending-limit rejection does.

---------

Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
Brennan Benson
2026-09-13 16:41:51 -07:00
committed by GitHub
co-authored by Merge Sim
parent 241fb9ed9d
commit 5e70014da8
36 changed files with 2053 additions and 177 deletions
+14 -2
View File
@@ -13,7 +13,8 @@ import {
resolveNativeFileDropPath,
type NativeDropResolution,
type NativeFileDropPayload,
type NativeFileDropPathEntry
type NativeFileDropPathEntry,
type NativeFileDropRejectedPayload
} from '../shared/native-file-drop'
/** Joins the synchronous unload checkpoint with its durable renderer write. */
@@ -133,7 +134,18 @@ export function installNativeFileDropHandlers(): void {
paths.push(filePath)
}
}
if (paths.length === 0 || resolution?.target === 'rejected') {
if (resolution?.target === 'rejected') {
return
}
if (paths.length === 0) {
// The OS offered file items we could read no path from (promised or
// virtual files). Report it — silence here is #15782.
ipcRenderer.send('terminal:file-dropped-from-preload', {
byteLength: 0,
pathCount: files.length,
reason: 'unresolved-paths',
target: 'rejected'
} satisfies NativeFileDropRejectedPayload)
return
}
const payload = createNativeFileDropPayload(resolution, paths)
@@ -348,6 +348,7 @@ export default function CombinedDiffViewer({
<CombinedDiffFileTree
mode={entrySet.treeMode}
worktreePath={file.filePath}
sourceWorkspaceId={file.worktreeId}
entries={entrySet.entries}
sectionIndexByKey={treeNavigation.sectionIndexByKey}
activeSectionKey={treeNavigation.activeTreeSectionKey}
@@ -0,0 +1,87 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
const testState: { executionHostId: ExecutionHostId } = vi.hoisted(() => ({
executionHostId: 'local'
}))
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getExecutionHostIdForWorktree: () => testState.executionHostId
}))
const { CombinedDiffFileTreeRow } = await import('./combined-diff-file-tree-row')
const { readWorkspaceFileDragSource } = await import('@/lib/workspace-file-drag')
globalThis.IS_REACT_ACT_ENVIRONMENT = true
const roots: Root[] = []
afterEach(() => {
roots.splice(0).forEach((root) => act(() => root.unmount()))
document.body.replaceChildren()
testState.executionHostId = 'local'
})
function renderRow(sourceWorkspaceId?: string): HTMLDivElement {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
act(() => {
root.render(
<CombinedDiffFileTreeRow
node={{
type: 'directory',
key: 'dir::unstaged::src',
path: 'src',
name: 'src',
depth: 0,
area: 'unstaged',
fileCount: 1,
children: []
}}
mode="uncommitted"
worktreePath="/repo/worktree"
sourceWorkspaceId={sourceWorkspaceId}
activeSectionKey={null}
sectionIndexByKey={new Map()}
isCollapsed={false}
onToggleDirectory={() => {}}
onNavigate={() => {}}
/>
)
})
return container
}
function dragRow(container: HTMLDivElement): DataTransfer {
const transfer = new DataTransfer()
const event = new Event('dragstart', { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', { value: transfer })
act(() => {
container.querySelector('[draggable="true"]')?.dispatchEvent(event)
})
return transfer
}
describe('combined diff rows stamp their drag source', () => {
// The tab's entry list is a snapshot, but the paths it drags belong to the
// workspace as it is owned now — the same answer the source-control rows give.
it('stamps the live owner of the workspace the diff belongs to', () => {
testState.executionHostId = 'runtime:env-1'
expect(readWorkspaceFileDragSource(dragRow(renderRow('wt-1')))).toEqual({
executionHostId: 'runtime:env-1',
workspaceId: 'wt-1'
})
})
it('leaves the drag unstamped when the owner or the workspace is unknown', () => {
expect(readWorkspaceFileDragSource(dragRow(renderRow(undefined)))).toBeNull()
testState.executionHostId = 'runtime:unresolved-owner'
expect(readWorkspaceFileDragSource(dragRow(renderRow('wt-1')))).toBeNull()
})
})
@@ -6,6 +6,7 @@ import { getFileTypeIcon } from '@/lib/file-type-icons'
import { basename, dirname, joinPath } from '@/lib/path'
import { cn } from '@/lib/utils'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
import { writeWorkspaceFileDragSourceForWorkspace } from '@/lib/workspace-file-drag-source'
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
import type {
GitFileStatus,
@@ -35,6 +36,7 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({
node,
mode,
worktreePath,
sourceWorkspaceId,
activeSectionKey,
sectionIndexByKey,
isCollapsed,
@@ -45,6 +47,7 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({
node: CombinedDiffTreeNode
mode: CombinedDiffFileTreeMode
worktreePath: string
sourceWorkspaceId?: string
activeSectionKey: string | null
sectionIndexByKey: ReadonlyMap<string, number>
isCollapsed: boolean
@@ -62,6 +65,9 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({
draggable
onDragStart={(event) => {
event.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, joinPath(worktreePath, node.path))
if (sourceWorkspaceId) {
writeWorkspaceFileDragSourceForWorkspace(event.dataTransfer, sourceWorkspaceId)
}
event.dataTransfer.effectAllowed = 'copy'
}}
>
@@ -117,6 +123,9 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({
WORKSPACE_FILE_PATH_MIME,
joinPath(worktreePath, node.entry.path)
)
if (sourceWorkspaceId) {
writeWorkspaceFileDragSourceForWorkspace(event.dataTransfer, sourceWorkspaceId)
}
event.dataTransfer.effectAllowed = 'copy'
}}
onClick={() => onNavigate(node.entry)}
@@ -19,6 +19,7 @@ export function CombinedDiffFileTreeRows({
rows,
mode,
worktreePath,
sourceWorkspaceId,
activeSectionKey,
sectionIndexByKey,
collapsedDirectoryKeys,
@@ -30,6 +31,7 @@ export function CombinedDiffFileTreeRows({
rows: readonly CombinedDiffTreeNode[]
mode: CombinedDiffFileTreeMode
worktreePath: string
sourceWorkspaceId?: string
activeSectionKey: string | null
sectionIndexByKey: ReadonlyMap<string, number>
collapsedDirectoryKeys: ReadonlySet<string>
@@ -50,6 +52,7 @@ export function CombinedDiffFileTreeRows({
node={node}
mode={mode}
worktreePath={worktreePath}
sourceWorkspaceId={sourceWorkspaceId}
activeSectionKey={activeSectionKey}
sectionIndexByKey={sectionIndexByKey}
isCollapsed={collapsedDirectoryKeys.has(node.key)}
@@ -36,6 +36,7 @@ const EMPTY_TREE_ROWS: CombinedDiffTreeNode[] = []
export function CombinedDiffFileTree({
mode,
worktreePath,
sourceWorkspaceId,
entries,
sectionIndexByKey,
activeSectionKey,
@@ -46,6 +47,7 @@ export function CombinedDiffFileTree({
}: {
mode: CombinedDiffFileTreeMode
worktreePath: string
sourceWorkspaceId?: string
entries: readonly CombinedDiffFileTreeEntry[]
sectionIndexByKey: ReadonlyMap<string, number>
activeSectionKey: string | null
@@ -200,6 +202,7 @@ export function CombinedDiffFileTree({
const sharedRowProps = {
mode,
worktreePath,
sourceWorkspaceId,
activeSectionKey,
sectionIndexByKey,
collapsedDirectoryKeys,
@@ -33,6 +33,7 @@ import { useNativeChatPtyComposerSend } from './use-native-chat-pty-composer-sen
import { useNativeChatStructuredComposerSend } from './use-native-chat-structured-composer-send'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
import { useNativeChatComposerAppMenuSelection } from './use-native-chat-composer-app-menu-selection'
import { useNativeChatWorkspaceFileDrop } from './use-native-chat-workspace-file-drop'
export type {
NativeChatComposerHandle,
@@ -173,6 +174,13 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
resolvePendingImageAttachment,
dropPendingImageAttachment
} = attachments
const workspaceFileDropHandlers = useNativeChatWorkspaceFileDrop({
terminalTabId,
structuredWorktreeId: structuredTransport?.worktreeId,
disabled,
attachResolvedPaths,
setNotice
})
// A pasted image has no agent-readable path until its save lands; sending
// mid-save would ship the message without the image the chip promises.
const hasPendingAttachment = imageAttachments.some((attachment) => attachment.pending)
@@ -413,6 +421,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
}}
onRemoveImageAttachment={(id) => removeImageAttachment(id)}
onAttach={pickAttachment}
workspaceFileDropHandlers={workspaceFileDropHandlers}
onDictationToggle={toggleDictation}
onDictationHoldStart={startHoldDictation}
onDictationHoldEnd={stopHoldDictation}
@@ -1,6 +1,11 @@
import { NativeChatPromptEditor } from './NativeChatPromptEditor'
import type { NativeChatComposerInput } from './native-chat-composer-input'
import type { ClipboardEventHandler, KeyboardEventHandler, RefObject } from 'react'
import type {
ClipboardEventHandler,
DragEventHandler,
KeyboardEventHandler,
RefObject
} from 'react'
import { useLayoutEffect, useRef } from 'react'
import { ImageOff } from 'lucide-react'
import type { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
@@ -48,6 +53,10 @@ export type NativeChatComposerFieldProps = {
onAcceptMention: () => void
onRemoveImageAttachment: (id: string) => void
onAttach: () => void
workspaceFileDropHandlers?: {
onDragOverCapture: DragEventHandler<HTMLDivElement>
onDropCapture: DragEventHandler<HTMLDivElement>
}
onDictationToggle: () => void
onDictationHoldStart: () => void
onDictationHoldEnd: () => void
@@ -120,6 +129,7 @@ export function NativeChatComposerField({
onAcceptMention,
onRemoveImageAttachment,
onAttach,
workspaceFileDropHandlers,
onDictationToggle,
onDictationHoldStart,
onDictationHoldEnd,
@@ -185,6 +195,7 @@ export function NativeChatComposerField({
</div>
) : null}
<div
{...workspaceFileDropHandlers}
data-native-file-drop-target={NATIVE_FILE_DROP_TARGET.composer}
data-composer-scope-key={composerScopeKey}
className={cn(
@@ -140,6 +140,18 @@ describe('resolveNativeChatAttachmentOwner', () => {
})
})
it('reports not-ready instead of throwing when the SSH generation is gone', () => {
expect(
resolveNativeChatAttachmentOwner(
state({
repos: [{ id: 'repo', connectionId: 'conn-1' }] as never,
sshConnectionStates: new Map()
}),
'tab-1'
)
).toEqual({ kind: 'not-ready' })
})
it('reports not-ready when an SSH worktree has no known path yet', () => {
expect(
resolveNativeChatAttachmentOwner(
@@ -82,11 +82,17 @@ export function resolveNativeChatAttachmentOwnerForWorktree(
if (!worktreePath) {
return { kind: 'not-ready' }
}
return {
kind: 'ssh',
connectionId,
worktreePath,
...captureDirectSshMutationExpectation(state, connectionId)
try {
return {
kind: 'ssh',
connectionId,
worktreePath,
...captureDirectSshMutationExpectation(state, connectionId)
}
} catch {
// The connection's generation is gone (disconnect mid-attach). That is an
// unknown owner, not a reason to throw out of the drop/IME handler.
return { kind: 'not-ready' }
}
}
@@ -97,6 +103,20 @@ export function nativeChatWorktreeNotReadyNotice(): string {
)
}
export function nativeChatAttachmentOwnerChangedNotice(): string {
return translate(
'components.native-chat.composer.attachmentOwnerChanged',
'This workspace changed hosts while attaching — drop the files again.'
)
}
export function nativeChatAttachmentUnreadableNotice(): string {
return translate(
'components.native-chat.composer.attachmentUnreadable',
"Couldn't read the dropped files."
)
}
export function nativeChatLocalAttachmentUnsupportedNotice(): string {
return translate(
'components.native-chat.composer.localAttachmentUnsupported',
@@ -3,7 +3,10 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render, screen } from '@testing-library/react'
import { useRef } from 'react'
import { useRef, useState } from 'react'
import type * as AttachmentUploadModule from './native-chat-attachment-upload'
import type { NativeChatComposerInput } from './native-chat-composer-input'
import { NativeChatPromptEditor } from './NativeChatPromptEditor'
import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments'
import { NativeChatImageAttachmentPreview } from './NativeChatImageAttachmentPreview'
import { resetLocalImageSrcStateForTests } from '../editor/useLocalImageSrc'
@@ -30,7 +33,9 @@ const intake = vi.hoisted(() => ({
upload: vi.fn()
}))
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
vi.mock('./native-chat-attachment-upload', () => ({
// Keeps the real notice strings so the silent-failure guards assert what users see.
vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({
...(await importOriginal<typeof AttachmentUploadModule>()),
resolveNativeChatAttachmentOwner: () => intake.owner,
uploadNativeChatAttachmentPaths: intake.upload
}))
@@ -49,7 +54,8 @@ import {
// Uses the production drop listener, subscriber fan-out, attachment hook, and scope cache.
function ComposerProbe({ pane, hidden = false }: { pane: string; hidden?: boolean }) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const textareaRef = useRef<NativeChatComposerInput>(null)
const [notice, setNotice] = useState<string | null>(null)
const attachments = useNativeChatComposerAttachments({
attachmentScopeKey: pane,
allowWithoutTarget: true,
@@ -60,22 +66,28 @@ function ComposerProbe({ pane, hidden = false }: { pane: string; hidden?: boolea
textareaRef,
setCaret: () => {},
setDraft: () => {},
setNotice: () => {}
setNotice
})
const { attachExternalPaths } = useNativeChatExternalAttachments({
terminalTabId: pane,
disabled: false,
attachResolvedPaths: attachments.attachResolvedPaths,
setNotice: () => {}
setNotice
})
useNativeChatFileAttachmentActions(pane, attachExternalPaths)
return (
<div data-pane={pane} style={{ display: hidden ? 'none' : 'block' }}>
<textarea
ref={textareaRef}
data-native-file-drop-target="composer"
data-composer-scope-key={pane}
/>
<div data-native-file-drop-target="composer" data-composer-scope-key={pane}>
<NativeChatPromptEditor
scopeKey={pane}
inputRef={textareaRef}
initialValue="untouched draft"
disabled={false}
placeholder="Message"
onChange={() => {}}
onSelect={() => {}}
/>
</div>
{attachments.imageAttachments.map((attachment) => (
<NativeChatImageAttachmentPreview
key={attachment.id}
@@ -84,10 +96,20 @@ function ComposerProbe({ pane, hidden = false }: { pane: string; hidden?: boolea
/>
))}
<output>{JSON.stringify(attachments.imageAttachments.map(({ path }) => path))}</output>
<output data-notice={pane}>{notice}</output>
</div>
)
}
/** The external-attach loop awaits once per path; drain those before asserting. */
async function settleAttachments(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
}
async function dropTwoImages(target: Element): Promise<void> {
const event = new Event('drop', { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', {
@@ -128,6 +150,7 @@ describe('native chat composer drop scoping', () => {
beforeEach(() => {
intake.owner = { kind: 'local' }
electron.getPathForFile.mockReset().mockImplementation((file: File) => `/repro/${file.name}`)
intake.authorizeExternalPath.mockReset().mockResolvedValue(undefined)
intake.readFile.mockReset().mockResolvedValue({ content: '', isBinary: false })
intake.upload.mockReset()
@@ -142,6 +165,51 @@ describe('native chat composer drop scoping', () => {
electron.send.mockClear()
})
// #15782: an OS drop that produces nothing must say so. Every assertion here
// is about the absence of silence, not about which path was attached.
it('reports an OS drop whose files carry no readable path', async () => {
electron.getPathForFile.mockReturnValue('')
const view = render(<ComposerProbe pane="chat-a" />)
await dropTwoImages(view.container.querySelector('[data-pane="chat-a"] .ProseMirror')!)
expect(electron.send).toHaveBeenCalledExactlyOnceWith('terminal:file-dropped-from-preload', {
byteLength: 0,
pathCount: 2,
reason: 'unresolved-paths',
target: 'rejected'
})
expect(readNativeChatAttachmentCache('chat-a')).toEqual([])
})
it('notices an OS drop whose every path fails authorization', async () => {
intake.authorizeExternalPath.mockRejectedValue(new Error('denied'))
const view = render(<ComposerProbe pane="chat-a" />)
await dropTwoImages(view.container.querySelector('[data-pane="chat-a"] .ProseMirror')!)
await settleAttachments()
expect(view.container.querySelector('[data-notice="chat-a"]')?.textContent).toBe(
"Couldn't read the dropped files."
)
expect(readNativeChatAttachmentCache('chat-a')).toEqual([])
})
it('notices an OS drop whose owner changes during authorization', async () => {
intake.authorizeExternalPath.mockImplementation(async () => {
intake.owner = { kind: 'ssh', connectionId: 'conn-1' }
})
const view = render(<ComposerProbe pane="chat-a" />)
await dropTwoImages(view.container.querySelector('[data-pane="chat-a"] .ProseMirror')!)
await settleAttachments()
expect(view.container.querySelector('[data-notice="chat-a"]')?.textContent).toBe(
'This workspace changed hosts while attaching — drop the files again.'
)
expect(readNativeChatAttachmentCache('chat-a')).toEqual([])
})
it('attaches only to the dropped pane and leaves a hidden pane clean on remount', async () => {
const view = render(
<>
@@ -152,7 +220,7 @@ describe('native chat composer drop scoping', () => {
expect(readNativeChatAttachmentCache('chat-a')).toEqual([])
expect(readNativeChatAttachmentCache('chat-b')).toEqual([])
const target = view.container.querySelector('[data-pane="chat-a"] textarea')!
const target = view.container.querySelector('[data-pane="chat-a"] .ProseMirror')!
await dropTwoImages(target)
expect(electron.send).toHaveBeenCalledExactlyOnceWith('terminal:file-dropped-from-preload', {
@@ -165,6 +233,7 @@ describe('native chat composer drop scoping', () => {
'/repro/second.png'
])
expect(readNativeChatAttachmentCache('chat-b')).toEqual([])
expect(target.textContent).toBe('untouched draft')
view.unmount()
const returned = render(<ComposerProbe pane="chat-b" />)
@@ -198,7 +267,7 @@ describe('native chat composer drop scoping', () => {
</>
)
await dropTwoImages(view.container.querySelector('[data-pane="chat-a"] textarea')!)
await dropTwoImages(view.container.querySelector('[data-pane="chat-a"] .ProseMirror')!)
expect(workspaceDrop).not.toHaveBeenCalled()
expect(readNativeChatAttachmentCache('chat-b')).toEqual([])
expect(readNativeChatAttachmentCache('chat-a').map(({ path }) => path)).toEqual([
@@ -239,7 +308,11 @@ describe('native chat composer drop scoping', () => {
<ComposerProbe pane="chat-b" hidden />
</>
)
await dropTwoImages(view.container.querySelector('[data-pane="chat-a"] textarea')!)
await dropTwoImages(
view.container.querySelector(
'[data-pane="chat-a"] [data-native-file-drop-target="composer"]'
)!
)
expect(await screen.findByRole('img', { name: 'first.png' })).toBeTruthy()
expect(await screen.findByRole('img', { name: 'second.png' })).toBeTruthy()
expect(intake.authorizeExternalPath.mock.calls).toEqual([
@@ -264,7 +337,7 @@ describe('native chat composer drop scoping', () => {
<ComposerProbe pane="chat-b" hidden />
</>
)
await dropTwoImages(view.container.querySelector('[data-pane="chat-a"] textarea')!)
await dropTwoImages(view.container.querySelector('[data-pane="chat-a"] .ProseMirror')!)
expect(intake.upload).toHaveBeenCalledExactlyOnceWith(
['/repro/first.png', '/repro/second.png'],
intake.owner
@@ -0,0 +1,506 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { useLayoutEffect, useRef, useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
encodeWorkspaceFilePaths,
WORKSPACE_FILE_PATHS_MIME,
WORKSPACE_FILE_PATH_MIME,
writeWorkspaceFileDragSource
} from '@/lib/workspace-file-drag'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type * as AttachmentUploadModule from './native-chat-attachment-upload'
import type { NativeChatComposerInput } from './native-chat-composer-input'
import { NativeChatComposerField } from './NativeChatComposerField'
import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments'
import { useNativeChatWorkspaceFileDrop } from './use-native-chat-workspace-file-drop'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
const testState: {
executionHostId: ExecutionHostId
ownerConnectionId: string
ownerKind: 'local' | 'not-ready' | 'runtime' | 'ssh'
ownerSshGeneration: number
ownerWorktreePath: string
targetIsRemoteRuntime: boolean
store: { tabsByWorktree: Record<string, { id: string }[]> }
} = vi.hoisted(() => ({
executionHostId: 'local',
ownerConnectionId: 'ssh-1',
ownerKind: 'local',
ownerSshGeneration: 4,
ownerWorktreePath: '/remote/repo',
targetIsRemoteRuntime: false,
store: {
tabsByWorktree: {
'worktree-1': [{ id: 'terminal-tab-1' }]
}
}
}))
vi.mock('@/store', () => {
const useAppStore = (selector: (state: typeof testState.store) => unknown) =>
selector(testState.store)
useAppStore.getState = () => testState.store
return { useAppStore }
})
vi.mock('@/lib/worktree-runtime-owner', () => ({
getExecutionHostIdForWorktree: () => testState.executionHostId
}))
// Real notice strings, so a copy of the wording here cannot outlive the string
// users actually read, and a newly added export cannot go missing from the mock.
vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({
...(await importOriginal<typeof AttachmentUploadModule>()),
resolveNativeChatAttachmentOwnerForWorktree: () =>
testState.ownerKind === 'ssh'
? {
kind: 'ssh',
connectionId: testState.ownerConnectionId,
worktreePath: testState.ownerWorktreePath,
expectedExecutionHostId: `ssh:${testState.ownerConnectionId}`,
expectedSshTargetId: testState.ownerConnectionId,
expectedSshConnectionGeneration: testState.ownerSshGeneration
}
: { kind: testState.ownerKind }
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
isRemoteRuntimePtyId: () => testState.targetIsRemoteRuntime
}))
vi.mock('./NativeChatComposerActions', () => ({
NativeChatComposerActions: () => <div data-testid="composer-actions" />
}))
vi.mock('./NativeChatAutocompleteMenus', () => ({
NativeChatMentionHint: () => null,
NativeChatPickerMenu: () => null
}))
vi.mock('./NativeChatImageAttachmentPreview', () => ({
NativeChatImageAttachmentPreview: ({
attachment
}: {
attachment: { connectionId?: string; path: string }
}) => (
<output data-image-attachment data-connection-id={attachment.connectionId}>
{attachment.path}
</output>
)
}))
class FileDragDataTransfer {
// Not 'none' and not 'copy': the browser picks a default we did not choose, so
// starting here is what makes an assertion on either verdict load-bearing.
dropEffect = 'link'
effectAllowed = 'copyMove'
files: File[] = []
private readonly data = new Map<string, string>()
get types(): string[] {
return [...this.data.keys()]
}
getData(type: string): string {
return this.data.get(type) ?? ''
}
setData(type: string, value: string): void {
this.data.set(type, value)
}
}
type ProbeProps = {
disabled?: boolean
initialDraft?: string
structured?: boolean
/** Overrides only the structured target, leaving the pane's scope key alone. */
structuredWorkspaceId?: string
workspaceId?: string
}
let latestInput: NativeChatComposerInput | null = null
const bubbledDrop = vi.fn()
function ComposerProbe({
disabled = false,
initialDraft = '',
structured = true,
structuredWorkspaceId,
workspaceId = 'worktree-1'
}: ProbeProps): React.JSX.Element {
const [draft, setDraft] = useState(initialDraft)
const [caret, setCaret] = useState(initialDraft.length)
const [notice, setNotice] = useState<string | null>(null)
const inputRef = useRef<NativeChatComposerInput>(null)
const imeEnterGesture = useImeEnterGestureOwnership()
const attachments = useNativeChatComposerAttachments({
attachmentScopeKey: `pane:${workspaceId}`,
allowWithoutTarget: structured,
caret,
disabled,
isComposing: imeEnterGesture.isComposing,
resolveTarget: () =>
structured ? null : { ptyId: 'pty-1', settings: { activeRuntimeEnvironmentId: null } },
textareaRef: inputRef,
setCaret,
setDraft,
setNotice
})
const workspaceFileDropHandlers = useNativeChatWorkspaceFileDrop({
terminalTabId: 'terminal-tab-1',
structuredWorktreeId: structured ? (structuredWorkspaceId ?? workspaceId) : undefined,
disabled,
attachResolvedPaths: attachments.attachResolvedPaths,
setNotice
})
useLayoutEffect(() => {
latestInput = inputRef.current
})
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={[]}
/>
<output data-testid="draft">{draft}</output>
</div>
)
}
function internalTransfer(
paths: string[],
source: { executionHostId?: ExecutionHostId; workspaceId?: string } = {}
): FileDragDataTransfer {
const transfer = new FileDragDataTransfer()
transfer.setData(WORKSPACE_FILE_PATH_MIME, paths[0] ?? '')
if (paths.length > 1) {
transfer.setData(WORKSPACE_FILE_PATHS_MIME, encodeWorkspaceFilePaths(paths))
}
writeWorkspaceFileDragSource(transfer, {
executionHostId: source.executionHostId ?? 'local',
workspaceId: source.workspaceId ?? 'worktree-1'
})
return transfer
}
function editor(): HTMLElement {
return screen.getByRole('textbox')
}
function dispatchDragEvent(
type: 'dragover' | 'drop',
target: Element,
dataTransfer: FileDragDataTransfer
): boolean {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', { value: dataTransfer })
let accepted = true
act(() => {
accepted = target.dispatchEvent(event)
})
return accepted
}
describe('native chat workspace file drops', () => {
beforeEach(() => {
testState.executionHostId = 'local'
testState.ownerConnectionId = 'ssh-1'
testState.ownerKind = 'local'
testState.ownerSshGeneration = 4
testState.ownerWorktreePath = '/remote/repo'
testState.targetIsRemoteRuntime = false
testState.store.tabsByWorktree = { 'worktree-1': [{ id: 'terminal-tab-1' }] }
latestInput = null
bubbledDrop.mockReset()
})
afterEach(() => {
cleanup()
})
it('consumes a nested editor drop once and inserts top-level paths at the caret', () => {
render(<ComposerProbe initialDraft="$rev tail" />)
act(() => {
latestInput!.insertSkill!(0, 4, '$review')
})
expect(editor().querySelectorAll('[data-native-chat-skill]')).toHaveLength(1)
const transfer = internalTransfer([
'/repo/src',
'/repo/src/index.ts',
'/repo/My File.ts',
'/repo/My File.ts'
])
transfer.setData('text/plain', 'must not be inserted by ProseMirror')
const accepted = dispatchDragEvent('drop', editor(), transfer)
expect(accepted).toBe(false)
expect(screen.getByTestId('draft').textContent).toBe(
'$review @/repo/src @"/repo/My File.ts" tail'
)
expect(editor().querySelectorAll('[data-native-chat-skill]')).toHaveLength(1)
expect(editor().textContent).not.toContain('must not be inserted')
expect(bubbledDrop).not.toHaveBeenCalled()
})
it('advertises a copy drop while leaving unrelated drags alone', () => {
render(<ComposerProbe />)
const internal = internalTransfer(['/repo/a.ts'])
const accepted = dispatchDragEvent('dragover', editor(), internal)
expect(accepted).toBe(false)
expect(internal.dropEffect).toBe('copy')
const unrelated = new FileDragDataTransfer()
unrelated.setData('text/plain', 'plain')
unrelated.setData('text/html', '<b>plain</b>')
dispatchDragEvent('dragover', editor(), unrelated)
dispatchDragEvent('drop', editor(), unrelated)
expect(bubbledDrop).toHaveBeenCalledOnce()
expect(screen.getByTestId('draft').textContent).toBe('')
})
const mismatchedSources: [string, { executionHostId?: ExecutionHostId; workspaceId?: string }][] =
[
['different workspace', { workspaceId: 'worktree-2' }],
['different execution host', { executionHostId: 'ssh:other' }]
]
it.each(mismatchedSources)('rejects paths from a %s', (_label, source) => {
render(<ComposerProbe />)
dispatchDragEvent('drop', editor(), internalTransfer(['/repo/a.ts'], source))
expect(screen.getByText('Files can only be attached to their source workspace.')).toBeTruthy()
expect(screen.getByTestId('draft').textContent).toBe('')
})
it('rejects legacy unscoped payloads and unavailable owners', () => {
const view = render(<ComposerProbe />)
const unscoped = new FileDragDataTransfer()
unscoped.setData(WORKSPACE_FILE_PATH_MIME, '/repo/a.ts')
dispatchDragEvent('drop', editor(), unscoped)
expect(screen.getByText('Files can only be attached to their source workspace.')).toBeTruthy()
testState.ownerKind = 'not-ready'
view.rerender(<ComposerProbe />)
dispatchDragEvent('drop', editor(), internalTransfer(['/repo/b.ts']))
expect(screen.getByText('Worktree not ready — try again in a moment.')).toBeTruthy()
expect(screen.getByTestId('draft').textContent).toBe('')
})
// A guarded composer must refuse visibly. It still claims the event, because
// the terminal surface behind it would otherwise paste the paths into the shell.
it('refuses the drag outright while disabled instead of promising a copy', () => {
render(<ComposerProbe disabled />)
const hover = internalTransfer(['/repo/a.ts'])
expect(dispatchDragEvent('dragover', editor(), hover)).toBe(false)
expect(hover.dropEffect).toBe('none')
expect(bubbledDrop).not.toHaveBeenCalled()
const transfer = internalTransfer(['/repo/a.ts'])
expect(dispatchDragEvent('drop', editor(), transfer)).toBe(false)
expect(transfer.dropEffect).toBe('none')
expect(screen.getByTestId('draft').textContent).toBe('')
})
// The relaxation that lets a runtime-owned path reach a runtime pane: the
// explorer lists that host's filesystem, so the agent can read what it drags.
it('accepts a same-host drop on a remote runtime target and refuses a foreign one', () => {
testState.executionHostId = 'runtime:env-1'
testState.ownerKind = 'runtime'
testState.targetIsRemoteRuntime = true
const view = render(<ComposerProbe structured={false} />)
dispatchDragEvent(
'drop',
editor(),
internalTransfer(['/env/owned.ts'], { executionHostId: 'runtime:env-1' })
)
expect(screen.getByTestId('draft').textContent).toBe('@/env/owned.ts ')
view.unmount()
render(<ComposerProbe structured={false} />)
dispatchDragEvent(
'drop',
editor(),
internalTransfer(['/elsewhere/foreign.ts'], { executionHostId: 'runtime:env-2' })
)
expect(screen.getByTestId('draft').textContent).toBe('')
expect(screen.getByText('Files can only be attached to their source workspace.')).toBeTruthy()
})
it('queues an internal reference until composition settles without stealing focus', () => {
render(<ComposerProbe initialDraft="preedit" />)
const input = editor()
act(() => latestInput!.setSelectionRange(7, 7))
input.focus()
fireEvent.compositionStart(input)
dispatchDragEvent('drop', input, internalTransfer(['/repo/a.ts']))
expect(screen.getByTestId('draft').textContent).toBe('preedit')
fireEvent.compositionEnd(input, { data: '' })
expect(screen.getByTestId('draft').textContent).toBe('preedit@/repo/a.ts ')
expect(document.activeElement).toBe(input)
})
it('rejects an IME-queued path when its execution host changes before composition settles', () => {
render(<ComposerProbe initialDraft="preedit" />)
const input = editor()
fireEvent.compositionStart(input)
dispatchDragEvent('drop', input, internalTransfer(['/repo/a.ts']))
testState.executionHostId = 'ssh:replacement'
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 an IME-queued path when its SSH route changes under the same host', () => {
testState.executionHostId = 'runtime:outer-env'
testState.ownerKind = 'ssh'
render(<ComposerProbe initialDraft="preedit" />)
const input = editor()
fireEvent.compositionStart(input)
dispatchDragEvent(
'drop',
input,
internalTransfer(['/remote/repo/a.ts'], { executionHostId: 'runtime:outer-env' })
)
testState.ownerConnectionId = 'ssh-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 an IME-queued path when the SSH connection reconnects under the same id', () => {
testState.executionHostId = 'ssh:ssh-1'
testState.ownerKind = 'ssh'
render(<ComposerProbe initialDraft="preedit" />)
const input = editor()
fireEvent.compositionStart(input)
dispatchDragEvent(
'drop',
input,
internalTransfer(['/remote/repo/a.ts'], { executionHostId: 'ssh:ssh-1' })
)
testState.ownerSshGeneration = 5
fireEvent.compositionEnd(input, { data: '' })
expect(screen.getByTestId('draft').textContent).toBe('preedit')
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 />)
dispatchDragEvent(
'drop',
editor(),
internalTransfer(['/repo/rejected.ts'], {
executionHostId: 'runtime:unresolved-owner'
})
)
expect(screen.getByTestId('draft').textContent).toBe('')
testState.executionHostId = 'runtime:my-unresolved-owner-env'
testState.ownerKind = 'runtime'
view.rerender(<ComposerProbe />)
dispatchDragEvent(
'drop',
editor(),
internalTransfer(['/repo/accepted.ts'], {
executionHostId: 'runtime:my-unresolved-owner-env'
})
)
expect(screen.getByTestId('draft').textContent).toBe('@/repo/accepted.ts ')
})
it('attaches same-owner SSH images without uploading or client authorization', () => {
testState.executionHostId = 'ssh:ssh-1'
testState.ownerKind = 'ssh'
render(<ComposerProbe />)
dispatchDragEvent(
'drop',
editor(),
internalTransfer(['/remote/repo/image.png'], {
executionHostId: 'ssh:ssh-1'
})
)
const image = screen.getByText('/remote/repo/image.png')
expect(image.getAttribute('data-connection-id')).toBe('ssh-1')
expect(screen.getByTestId('draft').textContent).toBe('')
})
it('supports PTY-owned and folder-workspace paths with the same ownership gate', () => {
const first = render(<ComposerProbe structured={false} />)
dispatchDragEvent('drop', editor(), internalTransfer(['/repo/pty.ts']))
expect(screen.getByTestId('draft').textContent).toBe('@/repo/pty.ts ')
first.unmount()
render(<ComposerProbe workspaceId="folder:folder-1" />)
dispatchDragEvent(
'drop',
editor(),
internalTransfer(['/folder/note.md'], { workspaceId: 'folder:folder-1' })
)
expect(screen.getByTestId('draft').textContent).toBe('@/folder/note.md ')
})
})
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
import { nativeChatAttachmentOwnerUnchanged } from './native-chat-resolved-path-ownership'
import type { NativeChatSshAttachmentOwner } from './native-chat-attachment-upload'
function ssh(overrides: Partial<NativeChatSshAttachmentOwner> = {}): NativeChatSshAttachmentOwner {
return {
kind: 'ssh',
connectionId: 'conn-1',
worktreePath: '/remote/wt',
expectedExecutionHostId: 'ssh:conn-1',
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4,
...overrides
}
}
describe('nativeChatAttachmentOwnerUnchanged', () => {
it('keeps same-kind local and runtime owners', () => {
expect(nativeChatAttachmentOwnerUnchanged({ kind: 'local' }, { kind: 'local' })).toBe(true)
expect(nativeChatAttachmentOwnerUnchanged({ kind: 'runtime' }, { kind: 'runtime' })).toBe(true)
})
it('never treats an unknown owner as the same owner', () => {
expect(nativeChatAttachmentOwnerUnchanged({ kind: 'not-ready' }, { kind: 'not-ready' })).toBe(
false
)
expect(nativeChatAttachmentOwnerUnchanged({ kind: 'local' }, { kind: 'not-ready' })).toBe(false)
})
it('rejects an SSH reconnect that keeps the same connection id', () => {
expect(nativeChatAttachmentOwnerUnchanged(ssh(), ssh())).toBe(true)
expect(
nativeChatAttachmentOwnerUnchanged(ssh(), ssh({ expectedSshConnectionGeneration: 5 }))
).toBe(false)
expect(nativeChatAttachmentOwnerUnchanged(ssh(), ssh({ connectionId: 'conn-2' }))).toBe(false)
expect(nativeChatAttachmentOwnerUnchanged(ssh(), ssh({ worktreePath: '/other' }))).toBe(false)
})
})
@@ -0,0 +1,35 @@
import { translate } from '@/i18n/i18n'
import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload'
export type NativeChatResolvedPathOptions = {
/** Revalidates internal path ownership when an IME-delayed attachment is applied. */
targetOwnerIsCurrent?: () => boolean
}
export function nativeChatWorkspaceAttachmentMismatchNotice(): string {
return translate(
'components.native-chat.composer.workspaceAttachmentMismatch',
'Files can only be attached to their source workspace.'
)
}
/** Whether an attachment captured against `captured` may still land on `current`.
* `not-ready` never matches: an unknown owner is not evidence of the same one. */
export function nativeChatAttachmentOwnerUnchanged(
captured: NativeChatAttachmentOwner,
current: NativeChatAttachmentOwner
): boolean {
if (captured.kind !== current.kind || captured.kind === 'not-ready') {
return false
}
if (captured.kind !== 'ssh' || current.kind !== 'ssh') {
return true
}
return (
captured.connectionId === current.connectionId &&
captured.worktreePath === current.worktreePath &&
captured.expectedExecutionHostId === current.expectedExecutionHostId &&
captured.expectedSshTargetId === current.expectedSshTargetId &&
captured.expectedSshConnectionGeneration === current.expectedSshConnectionGeneration
)
}
@@ -13,8 +13,9 @@ import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop'
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
const runtimeTarget = vi.hoisted(() => ({ remote: false }))
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
isRemoteRuntimePtyId: () => false
isRemoteRuntimePtyId: () => runtimeTarget.remote
}))
type AttachmentApi = ReturnType<typeof useNativeChatComposerAttachments>
@@ -129,6 +130,7 @@ async function renderProbe(
describe('useNativeChatComposerAttachments', () => {
afterEach(() => {
runtimeTarget.remote = false
clearNativeChatAttachmentCacheForTests()
document.body.replaceChildren()
})
@@ -169,6 +171,140 @@ describe('useNativeChatComposerAttachments', () => {
act(() => probe.root.unmount())
})
it('accepts only ownership-validated paths for a remote runtime target', async () => {
runtimeTarget.remote = true
const probe = await renderProbe('remote-pty')
act(() => probe.latest().attachResolvedPaths(['/remote/untrusted.txt']))
expect(probe.draft()).toBe('')
expect(probe.notice()).toBe('Local attachments are not available for remote sessions.')
act(() =>
probe.latest().attachResolvedPaths(['/remote/trusted.txt'], undefined, {
targetOwnerIsCurrent: () => true
})
)
expect(probe.draft()).toBe('@/remote/trusted.txt ')
act(() => probe.root.unmount())
})
it('rejects an ownership-validated path when its owner changes before IME flush', async () => {
let composing = true
let ownerCurrent = true
const probe = await renderProbe('pty-1', false, { isComposing: () => composing })
act(() =>
probe.latest().attachResolvedPaths(['/remote/trusted.txt'], undefined, {
targetOwnerIsCurrent: () => ownerCurrent
})
)
ownerCurrent = false
composing = false
act(() => probe.latest().flushPendingAttachments())
expect(probe.draft()).toBe('')
expect(probe.notice()).toBe('Files can only be attached to their source workspace.')
act(() => probe.root.unmount())
})
// Today's only caller settles ownership synchronously before it calls, so this
// verdict cannot arrive false — but the hook exports this entry point. Pinned
// because the fallback is not a refusal: a false verdict is not "owned", so a
// remote target would blame client-local attachments for an ownership failure.
it('names the ownership failure when an immediate attach arrives already false', async () => {
runtimeTarget.remote = true
const probe = await renderProbe('pty-1', false, { isComposing: () => false })
act(() =>
probe.latest().attachResolvedPaths(['/remote/moved.txt'], undefined, {
targetOwnerIsCurrent: () => false
})
)
expect(probe.draft()).toBe('')
expect(probe.notice()).toBe('Files can only be attached to their source workspace.')
act(() => probe.root.unmount())
})
// Ownership is per path: the target-owned drop still lands, the client-local
// paste is refused, and the refusal is reported rather than hidden.
it('keeps the owned half of a mixed queued batch after the target becomes remote', async () => {
let composing = true
const probe = await renderProbe('pty-1', false, { isComposing: () => composing })
act(() => {
probe.latest().attachResolvedPaths(['/remote/trusted.txt'], undefined, {
targetOwnerIsCurrent: () => true
})
probe.latest().attachResolvedPaths(['/local/untrusted.txt'])
})
runtimeTarget.remote = true
composing = false
act(() => probe.latest().flushPendingAttachments())
expect(probe.draft()).toBe('@/remote/trusted.txt ')
expect(probe.notice()).toBe('Local attachments are not available for remote sessions.')
act(() => probe.root.unmount())
})
// References are inserted in the order the user made them. Splitting the queue
// into an owned half and a client-local half would hoist every workspace drop
// ahead of a paste that came first.
it('keeps a mixed queued batch in the order it was attached', async () => {
let composing = true
const probe = await renderProbe('pty-1', false, { isComposing: () => composing })
act(() => {
probe.latest().attachResolvedPaths(['/local/first.txt'])
probe.latest().attachResolvedPaths(['/remote/second.txt'], undefined, {
targetOwnerIsCurrent: () => true
})
})
composing = false
act(() => probe.latest().flushPendingAttachments())
expect(probe.draft()).toBe('@/local/first.txt @/remote/second.txt ')
act(() => probe.root.unmount())
})
it('refuses a wholly client-local queued batch on a remote target', async () => {
let composing = true
const probe = await renderProbe('pty-1', false, { isComposing: () => composing })
act(() => probe.latest().attachResolvedPaths(['/local/untrusted.txt']))
runtimeTarget.remote = true
composing = false
act(() => probe.latest().flushPendingAttachments())
expect(probe.draft()).toBe('')
expect(probe.notice()).toBe('Local attachments are not available for remote sessions.')
act(() => probe.root.unmount())
})
// An already-blocked target refuses at the drop instead of queueing. Queued
// paths that can never attach would still spend the pending budget, and the
// next legitimate drop would be turned away for being one too many.
it('refuses an already-blocked target at the drop without spending the queue budget', async () => {
runtimeTarget.remote = true
let composing = true
const probe = await renderProbe('pty-1', false, { isComposing: () => composing })
const refused = Array.from(
{ length: NATIVE_FILE_DROP_MAX_PATHS },
(_unused, index) => `/local/refused-${index}.txt`
)
act(() => probe.latest().attachResolvedPaths(refused))
expect(probe.notice()).toBe('Local attachments are not available for remote sessions.')
runtimeTarget.remote = false
act(() => probe.latest().attachResolvedPaths(['/local/allowed.txt']))
composing = false
act(() => probe.latest().flushPendingAttachments())
expect(probe.draft()).toBe('@/local/allowed.txt ')
act(() => probe.root.unmount())
})
it('removes an attached image chip cleanly', async () => {
const probe = await renderProbe('pty-1')
await act(async () => {
@@ -1,15 +1,14 @@
import type { NativeChatComposerInput } from './native-chat-composer-input'
import { useCallback, useLayoutEffect, useRef, useState, type RefObject } from 'react'
import { useCallback, useRef, useState, type RefObject } from 'react'
import { translate } from '@/i18n/i18n'
import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop'
import { isNativeChatImageAttachmentPath } from './native-chat-image-paste'
import {
formatNativeChatFileReference,
nativeChatComposerTargetIsRemote,
type NativeChatResolvedTarget
} from './native-chat-composer-target'
import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache'
import type { NativeChatResolvedPathOptions } from './native-chat-resolved-path-ownership'
import { useNativeChatResolvedPathAttachments } from './use-native-chat-resolved-path-attachments'
export type UseNativeChatComposerAttachmentsArgs = {
attachmentScopeKey: string
@@ -37,7 +36,11 @@ export function useNativeChatComposerAttachments({
setNotice
}: UseNativeChatComposerAttachmentsArgs): {
imageAttachments: NativeChatComposerImageAttachment[]
attachResolvedPaths: (paths: string[], connectionId?: string | null) => void
attachResolvedPaths: (
paths: string[],
connectionId?: string | null,
options?: NativeChatResolvedPathOptions
) => void
clearImageAttachments: () => void
flushPendingAttachments: () => void
removeImageAttachment: (id: string) => void
@@ -49,17 +52,6 @@ export function useNativeChatComposerAttachments({
() => readNativeChatAttachmentCache(attachmentScopeKey)
)
const imageAttachmentCounter = useRef(0)
const pendingResolvedPathsRef = useRef<{ path: string; connectionId?: string | null }[]>([])
const pendingPathLimitRejectedRef = useRef(false)
const disabledRef = useRef(disabled)
useLayoutEffect(() => {
disabledRef.current = disabled
if (disabled) {
pendingResolvedPathsRef.current = []
pendingPathLimitRejectedRef.current = false
}
}, [disabled])
const updateImageAttachments = useCallback(
(
@@ -81,15 +73,18 @@ export function useNativeChatComposerAttachments({
return `${Date.now()}-${imageAttachmentCounter.current}`
}, [])
// Local paths are only attachable when the composer's target runs locally;
// remote-runtime panes read a different filesystem than the one we resolved.
const attachmentTargetBlocked = useCallback((): boolean => {
const target = resolveTarget()
return (
(!target && !allowWithoutTarget) ||
Boolean(target && nativeChatComposerTargetIsRemote(target.ptyId))
)
}, [allowWithoutTarget, resolveTarget])
// Client-local paths cannot cross into a runtime target; workspace-owned
// paths may only bypass this after the internal drop ownership gate.
const attachmentTargetBlocked = useCallback(
(targetOwned = false): boolean => {
const target = resolveTarget()
return (
(!target && !allowWithoutTarget) ||
Boolean(target && nativeChatComposerTargetIsRemote(target.ptyId) && !targetOwned)
)
},
[allowWithoutTarget, resolveTarget]
)
const noteAttachmentTargetBlocked = useCallback(() => {
setNotice(
@@ -117,6 +112,20 @@ export function useNativeChatComposerAttachments({
[nextAttachmentId, updateImageAttachments]
)
const { attachResolvedPaths, disabledRef, flushPendingAttachments } =
useNativeChatResolvedPathAttachments({
appendImageAttachments,
attachmentTargetBlocked,
caret,
disabled,
isComposing,
noteAttachmentTargetBlocked,
setCaret,
setDraft,
setNotice,
textareaRef
})
// Placeholder chip shown the instant a paste starts, so a clipboard image that
// takes a beat to save (or upload over SSH) never reads as a dropped paste.
const beginPendingImageAttachment = useCallback(
@@ -132,7 +141,13 @@ export function useNativeChatComposerAttachments({
updateImageAttachments((prev) => [...prev, { id, path: '', previewUrl, pending: true }])
return id
},
[attachmentTargetBlocked, nextAttachmentId, noteAttachmentTargetBlocked, updateImageAttachments]
[
attachmentTargetBlocked,
disabledRef,
nextAttachmentId,
noteAttachmentTargetBlocked,
updateImageAttachments
]
)
const resolvePendingImageAttachment = useCallback(
@@ -160,103 +175,6 @@ export function useNativeChatComposerAttachments({
[updateImageAttachments]
)
const insertFileReferences = useCallback(
(paths: string[]) => {
const references = paths.map(formatNativeChatFileReference).join(' ')
if (references.length === 0) {
return
}
const insertion = `${references} `
const caretAtInsert = textareaRef.current?.selectionStart ?? caret
setDraft((prev) => {
const before = prev.slice(0, caretAtInsert)
const after = prev.slice(caretAtInsert)
const next = before + insertion + after
setCaret(before.length + insertion.length)
return next
})
},
[caret, setCaret, setDraft, textareaRef]
)
// Attach paths the TARGET AGENT can read: local paths for local worktrees,
// already-uploaded remote paths for SSH worktrees (the composer uploads
// before calling this — see native-chat-attachment-upload.ts).
const applyResolvedPaths = useCallback(
(
resolvedPaths: { path: string; connectionId?: string | null }[],
focus: boolean,
preserveNotice = false
) => {
if (attachmentTargetBlocked()) {
noteAttachmentTargetBlocked()
return
}
const imagePaths = resolvedPaths.filter(({ path }) => isNativeChatImageAttachmentPath(path))
const filePaths = resolvedPaths
.filter(({ path }) => !isNativeChatImageAttachmentPath(path))
.map(({ path }) => path)
// Images are NOT sent to the TUI here — they ride along on submit (see
// NativeChatComposer.send) so the GUI chips and the TUI input never
// diverge and removing a chip needs no TUI un-paste.
appendImageAttachments(imagePaths.map(({ path, connectionId }) => ({ path, connectionId })))
insertFileReferences(filePaths)
if (!preserveNotice) {
setNotice(null)
}
if (focus && resolvedPaths.length > 0) {
requestAnimationFrame(() => textareaRef.current?.focus())
}
},
[
appendImageAttachments,
attachmentTargetBlocked,
insertFileReferences,
noteAttachmentTargetBlocked,
setNotice,
textareaRef
]
)
const attachResolvedPaths = useCallback(
(paths: string[], connectionId?: string | null) => {
if (paths.length === 0 || disabledRef.current) {
return
}
if (isComposing()) {
if (paths.length > NATIVE_FILE_DROP_MAX_PATHS - pendingResolvedPathsRef.current.length) {
// Reject the whole completion so ordered path batches are never partially applied.
pendingPathLimitRejectedRef.current = true
setNotice(
translate(
'components.native-chat.composer.pendingAttachmentLimit',
'Too many attachments are waiting. Finish composing before attaching more.'
)
)
return
}
pendingResolvedPathsRef.current.push(...paths.map((path) => ({ path, connectionId })))
return
}
applyResolvedPaths(
paths.map((path) => ({ path, connectionId })),
true
)
},
[applyResolvedPaths, isComposing, setNotice]
)
const flushPendingAttachments = useCallback(() => {
const paths = pendingResolvedPathsRef.current
const preserveNotice = pendingPathLimitRejectedRef.current
pendingResolvedPathsRef.current = []
pendingPathLimitRejectedRef.current = false
if (paths.length === 0 || disabledRef.current) {
return
}
applyResolvedPaths(paths, false, preserveNotice)
}, [applyResolvedPaths])
return {
imageAttachments,
attachResolvedPaths,
@@ -2,10 +2,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import type * as AttachmentUploadModule from './native-chat-attachment-upload'
const mocks = vi.hoisted(() => ({
authorizeExternalPath: vi.fn(),
resolveNativeChatAttachmentOwner: vi.fn(),
resolveNativeChatAttachmentOwnerForWorktree: vi.fn(),
uploadNativeChatAttachmentPaths: vi.fn()
}))
@@ -13,12 +15,13 @@ vi.mock('@/store', () => ({
useAppStore: { getState: () => ({}) }
}))
vi.mock('./native-chat-attachment-upload', () => ({
nativeChatLocalAttachmentUnsupportedNotice: () =>
'Local attachments are not available for remote sessions.',
// Real notice strings, so the tests below assert what a user would actually read
// and a newly added notice cannot go missing from this mock.
vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({
...(await importOriginal<typeof AttachmentUploadModule>()),
resolveNativeChatAttachmentOwner: mocks.resolveNativeChatAttachmentOwner,
uploadNativeChatAttachmentPaths: mocks.uploadNativeChatAttachmentPaths,
nativeChatWorktreeNotReadyNotice: () => 'Worktree not ready — try again in a moment.'
resolveNativeChatAttachmentOwnerForWorktree: mocks.resolveNativeChatAttachmentOwnerForWorktree,
uploadNativeChatAttachmentPaths: mocks.uploadNativeChatAttachmentPaths
}))
import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments'
@@ -35,11 +38,13 @@ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
function Probe({
disabled,
structuredWorktreeId,
attachResolvedPaths,
setNotice,
onReady
}: {
disabled: boolean
structuredWorktreeId?: string
attachResolvedPaths: (paths: string[]) => void
setNotice: (notice: string | null) => void
onReady: (api: HookApi) => void
@@ -47,6 +52,7 @@ function Probe({
onReady(
useNativeChatExternalAttachments({
terminalTabId: 'tab-1',
structuredWorktreeId,
disabled,
attachResolvedPaths,
setNotice
@@ -59,18 +65,26 @@ let root: Root | null = null
async function renderProbe(args: {
disabled?: boolean
structuredWorktreeId?: string
attachResolvedPaths: (paths: string[]) => void
setNotice?: (notice: string | null) => void
}): Promise<{ latest: () => HookApi; setDisabled: (disabled: boolean) => Promise<void> }> {
}): Promise<{
latest: () => HookApi
setDisabled: (disabled: boolean) => Promise<void>
setStructuredWorktreeId: (structuredWorktreeId: string) => Promise<void>
}> {
const container = document.createElement('div')
document.body.append(container)
let api: HookApi | null = null
root = createRoot(container)
const render = async (disabled: boolean): Promise<void> => {
let disabled = args.disabled ?? false
let structuredWorktreeId = args.structuredWorktreeId
const render = async (): Promise<void> => {
await act(async () => {
root?.render(
createElement(Probe, {
disabled,
structuredWorktreeId,
attachResolvedPaths: args.attachResolvedPaths,
setNotice: args.setNotice ?? (() => {}),
onReady: (next) => {
@@ -80,7 +94,7 @@ async function renderProbe(args: {
)
})
}
await render(args.disabled ?? false)
await render()
return {
latest: () => {
if (!api) {
@@ -88,12 +102,20 @@ async function renderProbe(args: {
}
return api
},
setDisabled: render
setDisabled: async (next) => {
disabled = next
await render()
},
setStructuredWorktreeId: async (next) => {
structuredWorktreeId = next
await render()
}
}
}
beforeEach(() => {
mocks.authorizeExternalPath.mockReset().mockResolvedValue(undefined)
mocks.resolveNativeChatAttachmentOwnerForWorktree.mockReset().mockReturnValue({ kind: 'local' })
window.api = {
fs: { authorizeExternalPath: mocks.authorizeExternalPath }
} as unknown as Window['api']
@@ -154,6 +176,111 @@ describe('useNativeChatExternalAttachments', () => {
expect(mocks.authorizeExternalPath).toHaveBeenCalledTimes(1)
})
it('does not attach local paths when the owner changes during authorization', async () => {
const authorization = deferred<void>()
let owner: { kind: 'local' } | { kind: 'runtime' } = { kind: 'local' }
mocks.resolveNativeChatAttachmentOwner.mockImplementation(() => owner)
mocks.authorizeExternalPath.mockReturnValueOnce(authorization.promise)
const attachResolvedPaths = vi.fn()
const notices: (string | null)[] = []
const probe = await renderProbe({
attachResolvedPaths,
setNotice: (notice) => notices.push(notice)
})
act(() => probe.latest().attachExternalPaths(['/external/a.png', '/external/b.png']))
owner = { kind: 'runtime' }
await act(async () => authorization.resolve())
expect(attachResolvedPaths).not.toHaveBeenCalled()
expect(mocks.authorizeExternalPath).toHaveBeenCalledTimes(1)
expect(notices.at(-1)).toBe(
'This workspace changed hosts while attaching — drop the files again.'
)
})
// The owner flipping during the LAST path has no next iteration to catch it,
// so the post-loop check is the only thing standing between a one-file drop
// and a path attached to a host that no longer owns it.
it('reports a one-file drop whose owner changes during its authorization', async () => {
const authorization = deferred<void>()
let owner: { kind: 'local' } | { kind: 'runtime' } = { kind: 'local' }
mocks.resolveNativeChatAttachmentOwner.mockImplementation(() => owner)
mocks.authorizeExternalPath.mockReturnValueOnce(authorization.promise)
const attachResolvedPaths = vi.fn()
const notices: (string | null)[] = []
const probe = await renderProbe({
attachResolvedPaths,
setNotice: (notice) => notices.push(notice)
})
act(() => probe.latest().attachExternalPaths(['/external/only.pdf']))
owner = { kind: 'runtime' }
await act(async () => authorization.resolve())
expect(attachResolvedPaths).not.toHaveBeenCalled()
expect(notices.at(-1)).toBe(
'This workspace changed hosts while attaching — drop the files again.'
)
})
// Both workspaces answer `local`, so the owner alone cannot tell them apart:
// only asking which workspace this composer serves now catches a tab that
// moved while the authorization was still in flight.
it('does not attach when the pane changes workspace during authorization', async () => {
const authorization = deferred<void>()
mocks.authorizeExternalPath.mockReturnValueOnce(authorization.promise)
const attachResolvedPaths = vi.fn()
const notices: (string | null)[] = []
const probe = await renderProbe({
structuredWorktreeId: 'worktree-1',
attachResolvedPaths,
setNotice: (notice) => notices.push(notice)
})
act(() => probe.latest().attachExternalPaths(['/external/only.pdf']))
await probe.setStructuredWorktreeId('worktree-2')
await act(async () => authorization.resolve())
expect(attachResolvedPaths).not.toHaveBeenCalled()
expect(notices.at(-1)).toBe(
'This workspace changed hosts while attaching — drop the files again.'
)
})
// The upload window is the long one: the paths go to the remote worktree the
// attach captured, so a pane that moved workspaces meanwhile must not receive
// remote paths that live under the workspace it left.
it('does not attach uploaded paths when the pane changes workspace during upload', async () => {
const sshOwner = {
kind: 'ssh',
connectionId: 'conn-1',
worktreePath: '/remote/wt',
expectedExecutionHostId: 'ssh:conn-1',
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4
} as const
mocks.resolveNativeChatAttachmentOwnerForWorktree.mockReturnValue(sshOwner)
const upload = deferred<string[]>()
mocks.uploadNativeChatAttachmentPaths.mockReturnValueOnce(upload.promise)
const attachResolvedPaths = vi.fn()
const notices: (string | null)[] = []
const probe = await renderProbe({
structuredWorktreeId: 'worktree-1',
attachResolvedPaths,
setNotice: (notice) => notices.push(notice)
})
act(() => probe.latest().attachExternalPaths(['/local/a.txt']))
await probe.setStructuredWorktreeId('worktree-2')
await act(async () => upload.resolve(['/remote/wt/.orca/drops/a.txt']))
expect(attachResolvedPaths).not.toHaveBeenCalled()
expect(notices.at(-1)).toBe(
'This workspace changed hosts while attaching — drop the files again.'
)
})
it('uploads SSH worktree paths and attaches the remote results', async () => {
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({
kind: 'ssh',
@@ -278,4 +405,34 @@ describe('useNativeChatExternalAttachments', () => {
})
expect(attachResolvedPaths).not.toHaveBeenCalled()
})
it('drops an upload that resolves after the SSH owner generation changes', async () => {
const initialOwner = {
kind: 'ssh' as const,
connectionId: 'conn-1',
worktreePath: '/remote/wt',
expectedExecutionHostId: 'ssh:conn-1' as const,
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4
}
mocks.resolveNativeChatAttachmentOwner
.mockReturnValueOnce(initialOwner)
.mockReturnValue({ ...initialOwner, expectedSshConnectionGeneration: 5 })
const upload = deferred<string[]>()
mocks.uploadNativeChatAttachmentPaths.mockReturnValue(upload.promise)
const attachResolvedPaths = vi.fn()
const notices: (string | null)[] = []
const probe = await renderProbe({
attachResolvedPaths,
setNotice: (notice) => notices.push(notice)
})
act(() => probe.latest().attachExternalPaths(['/local/a.txt']))
await act(async () => upload.resolve(['/remote/wt/.orca/drops/a.txt']))
expect(attachResolvedPaths).not.toHaveBeenCalled()
expect(notices.at(-1)).toBe(
'This workspace changed hosts while attaching — drop the files again.'
)
})
})
@@ -1,6 +1,9 @@
import { useCallback, useLayoutEffect, useRef } from 'react'
import { useAppStore } from '@/store'
import { nativeChatAttachmentOwnerUnchanged } from './native-chat-resolved-path-ownership'
import {
nativeChatAttachmentOwnerChangedNotice,
nativeChatAttachmentUnreadableNotice,
nativeChatLocalAttachmentUnsupportedNotice,
nativeChatWorktreeNotReadyNotice,
resolveNativeChatAttachmentOwner,
@@ -19,6 +22,15 @@ export type UseNativeChatExternalAttachmentsArgs = {
setNotice: (notice: string | null) => void
}
type ComposerWorkspace = { structuredWorktreeId?: string; terminalTabId: string }
function isSameComposerWorkspace(captured: ComposerWorkspace, current: ComposerWorkspace): boolean {
return (
captured.structuredWorktreeId === current.structuredWorktreeId &&
captured.terminalTabId === current.terminalTabId
)
}
/**
* Attach paths that arrived client-local (composer drop / file picker). SSH
* worktrees upload into the worktree's `.orca/drops` first so the remote agent
@@ -39,13 +51,23 @@ export function useNativeChatExternalAttachments({
disabledRef.current = disabled
}, [disabled])
const resolveAttachmentOwner = useCallback(
() =>
structuredWorktreeId
? resolveNativeChatAttachmentOwnerForWorktree(useAppStore.getState(), structuredWorktreeId)
: resolveNativeChatAttachmentOwner(useAppStore.getState(), terminalTabId),
[structuredWorktreeId, terminalTabId]
)
// The post-await gate asks which workspace this composer serves now, so it
// reads the pane through a ref. Resolving through the render closure would
// re-ask the workspace the upload started in — a comparison with itself.
const workspaceRef = useRef<ComposerWorkspace>({ structuredWorktreeId, terminalTabId })
useLayoutEffect(() => {
workspaceRef.current = { structuredWorktreeId, terminalTabId }
}, [structuredWorktreeId, terminalTabId])
const resolveAttachmentOwner = useCallback(() => {
const workspace = workspaceRef.current
return workspace.structuredWorktreeId
? resolveNativeChatAttachmentOwnerForWorktree(
useAppStore.getState(),
workspace.structuredWorktreeId
)
: resolveNativeChatAttachmentOwner(useAppStore.getState(), workspace.terminalTabId)
}, [])
const attachExternalPaths = useCallback(
(paths: string[]) => {
@@ -61,6 +83,15 @@ export function useNativeChatExternalAttachments({
setNotice(nativeChatLocalAttachmentUnsupportedNotice())
return
}
// Why every exit reports: a drop that reaches here and produces nothing is
// the silent-failure complaint in #15782. Only a disabled composer stays
// quiet — it is being torn down or guarded, and has no notice surface.
const capturedWorkspace = workspaceRef.current
// Both halves matter: a moved tab can land on a workspace that reports the
// same owner kind, and the owner alone would call that unchanged.
const ownerStillCurrent = (): boolean =>
isSameComposerWorkspace(capturedWorkspace, workspaceRef.current) &&
nativeChatAttachmentOwnerUnchanged(owner, resolveAttachmentOwner())
if (owner.kind !== 'ssh') {
void (async () => {
const authorizedPaths: string[] = []
@@ -68,6 +99,10 @@ export function useNativeChatExternalAttachments({
if (disabledRef.current) {
return
}
if (!ownerStillCurrent()) {
setNotice(nativeChatAttachmentOwnerChangedNotice())
return
}
try {
await window.api.fs.authorizeExternalPath({ targetPath })
authorizedPaths.push(targetPath)
@@ -75,15 +110,34 @@ export function useNativeChatExternalAttachments({
// Skip unreadable paths, matching workspace composer drops.
}
}
if (authorizedPaths.length > 0 && !disabledRef.current) {
attachResolvedPaths(authorizedPaths)
if (disabledRef.current) {
return
}
if (!ownerStillCurrent()) {
setNotice(nativeChatAttachmentOwnerChangedNotice())
return
}
if (authorizedPaths.length === 0) {
setNotice(nativeChatAttachmentUnreadableNotice())
return
}
attachResolvedPaths(authorizedPaths)
})()
return
}
void (async () => {
const remotePaths = await uploadNativeChatAttachmentPaths(paths, owner)
if (!remotePaths || remotePaths.length === 0 || disabledRef.current) {
if (disabledRef.current) {
return
}
if (!remotePaths || remotePaths.length === 0) {
// uploadNativeChatAttachmentPaths already toasted the IPC failure;
// an empty result with no failure means nothing was readable.
setNotice(nativeChatAttachmentUnreadableNotice())
return
}
if (!ownerStillCurrent()) {
setNotice(nativeChatAttachmentOwnerChangedNotice())
return
}
attachResolvedPaths(remotePaths, owner.connectionId)
@@ -0,0 +1,205 @@
import { useCallback, useLayoutEffect, useRef, type RefObject } from 'react'
import { translate } from '@/i18n/i18n'
import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop'
import { formatNativeChatFileReference } from './native-chat-composer-target'
import type { NativeChatComposerInput } from './native-chat-composer-input'
import { isNativeChatImageAttachmentPath } from './native-chat-image-paste'
import {
nativeChatWorkspaceAttachmentMismatchNotice,
type NativeChatResolvedPathOptions
} from './native-chat-resolved-path-ownership'
type ResolvedAttachmentPath = {
path: string
connectionId?: string | null
targetOwnerIsCurrent?: () => boolean
}
type Args = {
appendImageAttachments: (paths: { path: string; connectionId?: string | null }[]) => void
attachmentTargetBlocked: (targetOwned?: boolean) => boolean
caret: number
disabled: boolean
isComposing: () => boolean
noteAttachmentTargetBlocked: () => void
setCaret: (caret: number) => void
setDraft: (updater: (previous: string) => string) => void
setNotice: (notice: string | null) => void
textareaRef: RefObject<NativeChatComposerInput | null>
}
export function useNativeChatResolvedPathAttachments({
appendImageAttachments,
attachmentTargetBlocked,
caret,
disabled,
isComposing,
noteAttachmentTargetBlocked,
setCaret,
setDraft,
setNotice,
textareaRef
}: Args): {
attachResolvedPaths: (
paths: string[],
connectionId?: string | null,
options?: NativeChatResolvedPathOptions
) => void
disabledRef: RefObject<boolean>
flushPendingAttachments: () => void
} {
const pendingResolvedPathsRef = useRef<ResolvedAttachmentPath[]>([])
const pendingPathLimitRejectedRef = useRef(false)
const disabledRef = useRef(disabled)
useLayoutEffect(() => {
disabledRef.current = disabled
if (disabled) {
pendingResolvedPathsRef.current = []
pendingPathLimitRejectedRef.current = false
}
}, [disabled])
const insertFileReferences = useCallback(
(paths: string[]) => {
const references = paths.map(formatNativeChatFileReference).join(' ')
if (references.length === 0) {
return
}
const insertion = `${references} `
const caretAtInsert = textareaRef.current?.selectionStart ?? caret
setDraft((prev) => {
const before = prev.slice(0, caretAtInsert)
const after = prev.slice(caretAtInsert)
setCaret(before.length + insertion.length)
return before + insertion + after
})
},
[caret, setCaret, setDraft, textareaRef]
)
const applyResolvedPaths = useCallback(
(resolvedPaths: ResolvedAttachmentPath[], focus: boolean, preserveNotice = false) => {
if (resolvedPaths.length === 0) {
return
}
// A failed ownership verdict refuses the whole completion (see the limit
// rejection below): an ordered batch is never partially applied.
if (resolvedPaths.some(({ targetOwnerIsCurrent }) => targetOwnerIsCurrent?.() === false)) {
setNotice(nativeChatWorkspaceAttachmentMismatchNotice())
return
}
// Ownership is per path, so the verdict is too: a queued batch can mix a
// workspace drop the target owns with a client-local paste it does not,
// and one verdict for the batch would refuse the drop the user can make.
const ownedBlocked =
resolvedPaths.some(({ targetOwnerIsCurrent }) => targetOwnerIsCurrent) &&
attachmentTargetBlocked(true)
const clientLocalBlocked =
resolvedPaths.some(({ targetOwnerIsCurrent }) => !targetOwnerIsCurrent) &&
attachmentTargetBlocked(false)
// Filter rather than partition: the two halves are interleaved, and these
// references are inserted in the order the user attached them.
const attachable = resolvedPaths.filter(({ targetOwnerIsCurrent }) =>
targetOwnerIsCurrent ? !ownedBlocked : !clientLocalBlocked
)
if (attachable.length === 0) {
noteAttachmentTargetBlocked()
return
}
const imagePaths = attachable.filter(({ path }) => isNativeChatImageAttachmentPath(path))
const filePaths = attachable
.filter(({ path }) => !isNativeChatImageAttachmentPath(path))
.map(({ path }) => path)
// Images ride along on submit so chips and the TUI input cannot diverge.
appendImageAttachments(imagePaths.map(({ path, connectionId }) => ({ path, connectionId })))
insertFileReferences(filePaths)
if (ownedBlocked || clientLocalBlocked) {
noteAttachmentTargetBlocked()
} else if (!preserveNotice) {
setNotice(null)
}
if (focus) {
requestAnimationFrame(() => textareaRef.current?.focus())
}
},
[
appendImageAttachments,
attachmentTargetBlocked,
insertFileReferences,
noteAttachmentTargetBlocked,
setNotice,
textareaRef
]
)
const attachResolvedPaths = useCallback(
(
paths: string[],
connectionId?: string | null,
options: NativeChatResolvedPathOptions = {}
) => {
if (paths.length === 0 || disabledRef.current) {
return
}
const targetOwnerIsCurrent = options.targetOwnerIsCurrent?.()
if (targetOwnerIsCurrent === false) {
setNotice(nativeChatWorkspaceAttachmentMismatchNotice())
return
}
if (attachmentTargetBlocked(targetOwnerIsCurrent === true)) {
noteAttachmentTargetBlocked()
return
}
if (isComposing()) {
if (paths.length > NATIVE_FILE_DROP_MAX_PATHS - pendingResolvedPathsRef.current.length) {
// Reject the whole completion so ordered path batches are never partially applied.
pendingPathLimitRejectedRef.current = true
setNotice(
translate(
'components.native-chat.composer.pendingAttachmentLimit',
'Too many attachments are waiting. Finish composing before attaching more.'
)
)
return
}
pendingResolvedPathsRef.current.push(
...paths.map((path) => ({
path,
connectionId,
targetOwnerIsCurrent: options.targetOwnerIsCurrent
}))
)
return
}
applyResolvedPaths(
paths.map((path) => ({
path,
connectionId,
targetOwnerIsCurrent: options.targetOwnerIsCurrent
})),
true
)
},
[
applyResolvedPaths,
attachmentTargetBlocked,
isComposing,
noteAttachmentTargetBlocked,
setNotice
]
)
const flushPendingAttachments = useCallback(() => {
const paths = pendingResolvedPathsRef.current
const preserveNotice = pendingPathLimitRejectedRef.current
pendingResolvedPathsRef.current = []
pendingPathLimitRejectedRef.current = false
if (paths.length === 0 || disabledRef.current) {
return
}
applyResolvedPaths(paths, false, preserveNotice)
}, [applyResolvedPaths])
return { attachResolvedPaths, disabledRef, flushPendingAttachments }
}
@@ -0,0 +1,167 @@
import { useCallback, useLayoutEffect, useRef, type DragEventHandler } from 'react'
import { useAppStore } from '@/store'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import {
getWorkspaceFileDragRejectionMessage,
hasWorkspaceFileDragType,
isResolvedWorkspaceFileDragExecutionHost,
readWorkspaceFileDragPaths,
readWorkspaceFileDragSource
} from '@/lib/workspace-file-drag'
import {
resolveNativeChatAttachmentOwnerForWorktree,
nativeChatWorktreeNotReadyNotice
} from './native-chat-attachment-upload'
import { findTerminalTabWorktreeId } from './native-chat-file-link'
import {
nativeChatAttachmentOwnerUnchanged,
nativeChatWorkspaceAttachmentMismatchNotice,
type NativeChatResolvedPathOptions
} from './native-chat-resolved-path-ownership'
type WorkspaceFileDropHandlers = {
onDragOverCapture: DragEventHandler<HTMLDivElement>
onDropCapture: DragEventHandler<HTMLDivElement>
}
type Args = {
attachResolvedPaths: (
paths: string[],
connectionId?: string | null,
options?: NativeChatResolvedPathOptions
) => void
disabled: boolean
setNotice: (notice: string | null) => void
structuredWorktreeId?: string
terminalTabId: string
}
// The composer sits inside the terminal surface, which accepts the same drag and
// pastes it into the shell. Claiming the event here is what keeps a drop aimed at
// the composer out of the terminal behind it — including when we refuse it.
function claimWorkspaceFileDrag(event: React.DragEvent<HTMLDivElement>): void {
event.preventDefault()
event.stopPropagation()
}
function setDropEffect(dataTransfer: DataTransfer, effect: 'copy' | 'none'): void {
if (effect === 'none') {
dataTransfer.dropEffect = 'none'
return
}
if (
dataTransfer.effectAllowed === 'all' ||
dataTransfer.effectAllowed === 'copy' ||
dataTransfer.effectAllowed === 'copyLink' ||
dataTransfer.effectAllowed === 'copyMove' ||
dataTransfer.effectAllowed === 'uninitialized'
) {
dataTransfer.dropEffect = 'copy'
}
}
export function useNativeChatWorkspaceFileDrop({
attachResolvedPaths,
disabled,
setNotice,
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)) {
return
}
claimWorkspaceFileDrag(event)
// A guarded composer answers `none` rather than promising a copy it will
// then drop on the floor: the cursor refuses, and no drop event follows.
setDropEffect(event.dataTransfer, disabled ? 'none' : 'copy')
},
[disabled]
)
const onDropCapture = useCallback<DragEventHandler<HTMLDivElement>>(
(event) => {
if (!hasWorkspaceFileDragType(event.dataTransfer)) {
return
}
claimWorkspaceFileDrag(event)
if (disabled) {
setDropEffect(event.dataTransfer, 'none')
return
}
setDropEffect(event.dataTransfer, 'copy')
const dragPaths = readWorkspaceFileDragPaths(event.dataTransfer)
if (dragPaths.status === 'rejected') {
setNotice(getWorkspaceFileDragRejectionMessage(dragPaths.reason))
return
}
if (dragPaths.paths.length === 0) {
return
}
const state = useAppStore.getState()
const workspaceId =
structuredWorktreeId ?? findTerminalTabWorktreeId(state.tabsByWorktree, terminalTabId)
const source = readWorkspaceFileDragSource(event.dataTransfer)
if (!workspaceId || !source || source.workspaceId !== workspaceId) {
setNotice(nativeChatWorkspaceAttachmentMismatchNotice())
return
}
const owner = resolveNativeChatAttachmentOwnerForWorktree(
state,
workspaceId,
structuredWorktreeId ? undefined : terminalTabId
)
if (owner.kind === 'not-ready') {
setNotice(nativeChatWorktreeNotReadyNotice())
return
}
const targetExecutionHostId = getExecutionHostIdForWorktree(state, workspaceId)
if (
!isResolvedWorkspaceFileDragExecutionHost(targetExecutionHostId) ||
source.executionHostId !== targetExecutionHostId
) {
setNotice(nativeChatWorkspaceAttachmentMismatchNotice())
return
}
const targetOwnerIsCurrent = (): boolean => {
const currentState = useAppStore.getState()
const currentWorkspaceId =
structuredWorktreeIdRef.current ??
findTerminalTabWorktreeId(currentState.tabsByWorktree, terminalTabId)
if (currentWorkspaceId !== source.workspaceId) {
return false
}
const currentHostId = getExecutionHostIdForWorktree(currentState, currentWorkspaceId)
const currentOwner = resolveNativeChatAttachmentOwnerForWorktree(
currentState,
currentWorkspaceId,
structuredWorktreeIdRef.current ? undefined : terminalTabId
)
return (
isResolvedWorkspaceFileDragExecutionHost(currentHostId) &&
currentHostId === source.executionHostId &&
nativeChatAttachmentOwnerUnchanged(owner, currentOwner)
)
}
attachResolvedPaths(dragPaths.paths, owner.kind === 'ssh' ? owner.connectionId : undefined, {
targetOwnerIsCurrent
})
},
[attachResolvedPaths, disabled, setNotice, structuredWorktreeId, terminalTabId]
)
return { onDragOverCapture, onDropCapture }
}
@@ -183,6 +183,8 @@ export function FileExplorerFilesTreePane({
flashingPath={flashingPath}
deleteShortcutLabel={deletion.deleteShortcutLabel}
connectionId={activeRepo?.connectionId ?? null}
sourceWorkspaceId={tree.sourceWorkspaceId}
dirCache={tree.dirCache}
runtimeDownloadContext={runtimeDownloadContext}
supportsFolderDownload={supportsFolderDownload}
canOpenInOrcaBrowser={canOpenWorkspaceFileBrowserForPath}
@@ -6,8 +6,10 @@ import { getFileTypeIcon } from '@/lib/file-type-icons'
import {
encodeWorkspaceFilePaths,
WORKSPACE_FILE_PATH_MIME,
WORKSPACE_FILE_PATHS_MIME
WORKSPACE_FILE_PATHS_MIME,
writeWorkspaceFileDragSourceIfResolved
} from '@/lib/workspace-file-drag'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { GitFileStatus } from '../../../../shared/git-status-types'
import { STATUS_LABELS } from './status-display'
import { RENAME_HOTSPOT_ATTR } from './file-explorer-dir-toggle-timing'
@@ -33,6 +35,9 @@ export type FileExplorerRowProps = {
isIgnored: boolean
deleteShortcutLabel: string
connectionId?: string | null
sourceWorkspaceId?: string | null
/** Resolved at dragstart so the virtualized list pays nothing per render. */
resolveDragSourceHostId?: (paths: readonly string[]) => ExecutionHostId | null
runtimeDownloadContext?: RuntimeFileOperationArgs | null
supportsFolderDownload?: boolean
canOpenInOrcaBrowser: boolean
@@ -74,6 +79,8 @@ export function FileExplorerRow({
isIgnored,
deleteShortcutLabel,
connectionId,
sourceWorkspaceId,
resolveDragSourceHostId,
runtimeDownloadContext,
supportsFolderDownload = false,
canOpenInOrcaBrowser,
@@ -153,6 +160,11 @@ export function FileExplorerRow({
if (paths.length > 1) {
event.dataTransfer.setData(WORKSPACE_FILE_PATHS_MIME, encodeWorkspaceFilePaths(paths))
}
writeWorkspaceFileDragSourceIfResolved(
event.dataTransfer,
sourceWorkspaceId,
resolveDragSourceHostId?.(paths)
)
event.dataTransfer.effectAllowed = 'copyMove'
onDragSourceChange(node.path)
@@ -6,9 +6,11 @@ import type { GitFileStatus } from '../../../../shared/git-status-types'
import { FileExplorerRow } from './FileExplorerRow'
import { InlineInputRow, type InlineInput } from './file-explorer-inline-input-row'
import { shouldShowIgnoredDecoration, STATUS_COLORS } from './status-display'
import type { TreeNode } from './file-explorer-types'
import type { DirCache, FileExplorerOperationOwner, TreeNode } from './file-explorer-types'
import type { FileExplorerRowProjection } from './file-explorer-row-projection'
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
import { getFileExplorerOperationExecutionHostId } from './file-explorer-operation-owner'
import type { ExecutionHostId } from '../../../../shared/execution-host'
type FileExplorerVirtualRowsProps = {
virtualizer: Virtualizer<HTMLDivElement, Element>
@@ -28,6 +30,10 @@ type FileExplorerVirtualRowsProps = {
flashingPath: string | null
deleteShortcutLabel: string
connectionId?: string | null
sourceWorkspaceId?: string | null
/** Listings behind the projection, so a drag can name the owner of a selected
* path whose row is currently hidden. */
dirCache?: Record<string, DirCache>
runtimeDownloadContext?: RuntimeFileOperationArgs | null
supportsFolderDownload?: boolean
canOpenInOrcaBrowser?: (filePath: string) => boolean
@@ -56,6 +62,44 @@ type FileExplorerVirtualRowsProps = {
nativeDropTargetDir: string | null
}
/** The owner of a dragged path, from the visible row when there is one and from
* the cached listing when there is not. A selection survives collapsing a
* directory, a name filter and the dotfile toggle, and the drag still carries
* those paths — the projection only stopped indexing them, the cache still
* records which host listed them. */
function getDraggedPathOperationOwner(
rowProjection: FileExplorerRowProjection,
dirCache: Record<string, DirCache> | undefined,
path: string
): FileExplorerOperationOwner | undefined {
const visibleOwner = rowProjection.getRowByPath(path)?.operationOwner
if (visibleOwner || !dirCache) {
return visibleOwner
}
const parent = dirCache[dirname(path)]
return parent?.children.find((child) => child.path === path)?.operationOwner
}
/** Null unless every dragged row came from one host: a mixed-owner drag has no
* single source to stamp, so it must fail closed at the drop target. */
function resolveDragSourceExecutionHostId(
rowProjection: FileExplorerRowProjection,
dirCache: Record<string, DirCache> | undefined,
paths: readonly string[]
): ExecutionHostId | null {
let sourceExecutionHostId: ExecutionHostId | null = null
for (const path of paths) {
const executionHostId = getFileExplorerOperationExecutionHostId(
getDraggedPathOperationOwner(rowProjection, dirCache, path)
)
if (!executionHostId || (sourceExecutionHostId && executionHostId !== sourceExecutionHostId)) {
return null
}
sourceExecutionHostId = executionHostId
}
return sourceExecutionHostId
}
export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): React.JSX.Element {
const {
virtualizer,
@@ -75,6 +119,8 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re
flashingPath,
deleteShortcutLabel,
connectionId,
sourceWorkspaceId,
dirCache,
runtimeDownloadContext,
supportsFolderDownload = false,
canOpenInOrcaBrowser = () => false,
@@ -104,6 +150,10 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re
} = props
const visibleSelectionCount = rowProjection.countVisiblePaths(selectedPaths)
// Resolved at dragstart, not per render: the virtualizer re-renders on every
// scroll frame and only a drag ever reads this.
const resolveDragSourceHostId = (paths: readonly string[]): ExecutionHostId | null =>
resolveDragSourceExecutionHostId(rowProjection, dirCache, paths)
return (
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
@@ -181,6 +231,8 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re
isIgnored={isIgnored}
deleteShortcutLabel={deleteShortcutLabel}
connectionId={connectionId}
sourceWorkspaceId={sourceWorkspaceId}
resolveDragSourceHostId={resolveDragSourceHostId}
runtimeDownloadContext={runtimeDownloadContext}
supportsFolderDownload={supportsFolderDownload}
canOpenInOrcaBrowser={canOpenInOrcaBrowser(n.path)}
@@ -6,8 +6,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { FileExplorerVirtualRows } from './FileExplorerVirtualRows'
import { useFileExplorerHandlers } from './useFileExplorerHandlers'
import { createFileExplorerRowProjection } from './file-explorer-row-projection'
import { createVisibleFileExplorerRowProjection } from './useFileExplorerVisibleRowProjection'
import { FILE_EXPLORER_DRAGGABLE_SELECTOR } from './file-explorer-drag-scroll-marker'
import type { TreeNode } from './file-explorer-types'
import type { DirCache, TreeNode } from './file-explorer-types'
import { readWorkspaceFileDragSource } from '@/lib/workspace-file-drag'
globalThis.IS_REACT_ACT_ENVIRONMENT = true
@@ -47,7 +49,15 @@ const directoryNode: TreeNode = {
depth: 0
}
function virtualRowsElement(nodes: TreeNode[]): React.JSX.Element {
function virtualRowsElement(
nodes: TreeNode[],
options: {
dirCache?: Record<string, DirCache>
selectedPaths?: Set<string>
sourceWorkspaceId?: string
rowProjection?: ReturnType<typeof createFileExplorerRowProjection>
} = {}
): React.JSX.Element {
return FileExplorerVirtualRows({
virtualizer: {
getTotalSize: () => nodes.length * 26,
@@ -56,7 +66,7 @@ function virtualRowsElement(nodes: TreeNode[]): React.JSX.Element {
measureElement: vi.fn()
} as never,
inlineInputIndex: -1,
rowProjection: createFileExplorerRowProjection(nodes),
rowProjection: options.rowProjection ?? createFileExplorerRowProjection(nodes),
inlineInput: null,
handleInlineSubmit: vi.fn(),
dismissInlineInput: vi.fn(),
@@ -65,10 +75,12 @@ function virtualRowsElement(nodes: TreeNode[]): React.JSX.Element {
ignoredByRelativePath: new Set(),
expanded: new Set(),
loadingDirPaths: new Set<string>(),
selectedPaths: new Set(),
selectedPaths: options.selectedPaths ?? new Set(),
activeFileId: null,
flashingPath: null,
deleteShortcutLabel: 'Del',
sourceWorkspaceId: options.sourceWorkspaceId,
dirCache: options.dirCache,
onClick: vi.fn(),
onDoubleClick: vi.fn(),
onContextMenuSelect: vi.fn(),
@@ -107,6 +119,141 @@ describe('file explorer draggable rows carry the wheel-scroll marker', () => {
expect(button.matches(FILE_EXPLORER_DRAGGABLE_SELECTOR)).toBe(true)
})
})
it('stamps a row with the workspace and host that produced its cached node', async () => {
const cachedNode: TreeNode = {
...fileNode,
operationOwner: {
kind: 'runtime',
environmentId: 'old-env',
executionHostId: 'runtime:old-env'
}
}
const container = await renderToBody(
virtualRowsElement([cachedNode], { sourceWorkspaceId: 'old-workspace' })
)
const transfer = new DataTransfer()
const event = new Event('dragstart', { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', { value: transfer })
container.querySelector('[data-file-explorer-row]')?.dispatchEvent(event)
expect(readWorkspaceFileDragSource(transfer)).toEqual({
executionHostId: 'runtime:old-env',
workspaceId: 'old-workspace'
})
})
it('omits ownership when selected cached rows came from different hosts', async () => {
const localNode: TreeNode = { ...fileNode, operationOwner: { kind: 'local' } }
const sshNode: TreeNode = {
...directoryNode,
operationOwner: { kind: 'ssh', connectionId: 'remote-1' }
}
const container = await renderToBody(
virtualRowsElement([localNode, sshNode], {
selectedPaths: new Set([localNode.path, sshNode.path]),
sourceWorkspaceId: 'workspace-1'
})
)
const transfer = new DataTransfer()
const event = new Event('dragstart', { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', { value: transfer })
container.querySelector('[data-file-explorer-row]')?.dispatchEvent(event)
expect(readWorkspaceFileDragSource(transfer)).toBeNull()
})
// A selection outlives the rows that showed it: nothing prunes selectedPaths
// when a directory collapses, and the drag still carries every selected path.
// Resolving only against visible rows refused a drag whose owner the cache
// knows perfectly well.
it('stamps a selection that reaches under a collapsed directory', async () => {
const owner = { kind: 'local' } as const
const collapsedChild: TreeNode = {
name: 'a.ts',
path: '/repo/src/a.ts',
relativePath: 'src/a.ts',
isDirectory: false,
depth: 1,
operationOwner: owner
}
const readme: TreeNode = {
name: 'README.md',
path: '/repo/README.md',
relativePath: 'README.md',
isDirectory: false,
depth: 0,
operationOwner: owner
}
const dirCache = {
'/repo': {
children: [{ ...directoryNode, operationOwner: owner }, readme],
operationOwner: owner
},
'/repo/src': { children: [collapsedChild], operationOwner: owner }
}
// `expanded` is empty, so /repo/src is collapsed and a.ts is not a row.
const projection = createVisibleFileExplorerRowProjection(
{ dirCache, expanded: new Set<string>(), worktreePath: '/repo' },
{
ignoredSet: new Set<string>(),
nameFilter: null,
showDotfiles: true,
showGitIgnoredFiles: true
}
)
expect(projection.getRowByPath(collapsedChild.path)).toBeNull()
const container = await renderToBody(
virtualRowsElement([directoryNode, readme], {
dirCache,
rowProjection: projection,
selectedPaths: new Set([readme.path, collapsedChild.path]),
sourceWorkspaceId: 'workspace-1'
})
)
const transfer = new DataTransfer()
const event = new Event('dragstart', { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', { value: transfer })
container.querySelectorAll('[data-file-explorer-row]')[1]?.dispatchEvent(event)
expect(readWorkspaceFileDragSource(transfer)).toEqual({
executionHostId: 'local',
workspaceId: 'workspace-1'
})
})
// The virtualizer re-renders on every scroll frame; a per-render owner scan
// over the whole selection would be paid on each of them.
it('resolves drag ownership at dragstart, not while rendering rows', async () => {
const localNode: TreeNode = { ...fileNode, operationOwner: { kind: 'local' } }
const otherNode: TreeNode = { ...directoryNode, operationOwner: { kind: 'local' } }
const projection = createFileExplorerRowProjection([localNode, otherNode])
const getRowByPath = vi.fn(projection.getRowByPath)
const container = await renderToBody(
virtualRowsElement([localNode, otherNode], {
rowProjection: { ...projection, getRowByPath },
selectedPaths: new Set([localNode.path, otherNode.path]),
sourceWorkspaceId: 'workspace-1'
})
)
expect(getRowByPath).not.toHaveBeenCalled()
const transfer = new DataTransfer()
const event = new Event('dragstart', { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', { value: transfer })
container.querySelector('[data-file-explorer-row]')?.dispatchEvent(event)
expect(getRowByPath.mock.calls.map(([path]) => path)).toEqual([localNode.path, otherNode.path])
expect(readWorkspaceFileDragSource(transfer)).toEqual({
executionHostId: 'local',
workspaceId: 'workspace-1'
})
})
})
let capturedHandlers: ReturnType<typeof useFileExplorerHandlers> | null = null
@@ -234,6 +234,12 @@ function getFileExplorerGenerationRoute(
}
}
export function getFileExplorerOperationExecutionHostId(
owner: FileExplorerOperationOwner | undefined
): ExecutionHostId | null {
return getFileExplorerGenerationRoute(owner)?.executionHostId ?? null
}
export function getFileExplorerOwnerUnresolvedMessage(): string {
return translate(
'auto.components.right.sidebar.fileExplorerOperationOwner.unresolved',
@@ -3,6 +3,7 @@ import { MessageSquare } from 'lucide-react'
import { getFileTypeIcon } from '@/lib/file-type-icons'
import { basename, dirname, joinPath } from '@/lib/path'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
import { writeWorkspaceFileDragSourceForWorkspace } from '@/lib/workspace-file-drag-source'
import { translate } from '@/i18n/i18n'
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
import { DiffLineCounts } from './diff-line-counts'
@@ -55,6 +56,7 @@ export function BranchEntryRow({
onDragStart={(e) => {
const absolutePath = joinPath(worktreePath, entry.path)
e.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, absolutePath)
writeWorkspaceFileDragSourceForWorkspace(e.dataTransfer, currentWorktreeId)
e.dataTransfer.effectAllowed = 'copy'
}}
onClick={(e) => onOpen(e)}
@@ -4,6 +4,7 @@ import { getFileTypeIcon } from '@/lib/file-type-icons'
import { basename, dirname, joinPath } from '@/lib/path'
import { cn } from '@/lib/utils'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
import { writeWorkspaceFileDragSourceForWorkspace } from '@/lib/workspace-file-drag-source'
import { translate } from '@/i18n/i18n'
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
import { ActionButton } from './action-button'
@@ -122,6 +123,7 @@ export const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
}
const absolutePath = joinPath(worktreePath, entry.path)
e.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, absolutePath)
writeWorkspaceFileDragSourceForWorkspace(e.dataTransfer, currentWorktreeId)
e.dataTransfer.effectAllowed = 'copy'
}}
onClick={(e) => {
@@ -152,4 +152,26 @@ describe('useFileExplorerTree stale collapsed dirs', () => {
})
expect(result.current.isDirStale('/repo/src')).toBe(false)
})
it('keeps the rendered cache bound to its loaded workspace until reset', async () => {
const props = { path: '/repo', worktreeId: 'wt-1' }
const { result, rerender } = renderHook(
({ path, worktreeId }: typeof props) => useFileExplorerTree(path, new Set(), worktreeId),
{ initialProps: props }
)
await act(async () => {
await result.current.loadDir('/repo', -1)
})
expect(result.current.sourceWorkspaceId).toBe('wt-1')
rerender({ path: '/repo', worktreeId: 'wt-2' })
expect(result.current.sourceWorkspaceId).toBe('wt-1')
await act(async () => {
result.current.resetAndLoad()
await Promise.resolve()
})
expect(result.current.sourceWorkspaceId).toBe('wt-2')
})
})
@@ -26,6 +26,8 @@ import {
type UseFileExplorerTreeResult = {
dirCache: Record<string, DirCache>
setDirCache: Dispatch<SetStateAction<Record<string, DirCache>>>
/** Workspace whose committed root listing owns the rendered cache. */
sourceWorkspaceId: string | null
/** Dirs with a read in flight — kept out of dirCache so the row projection does not rebuild. */
loadingDirPaths: ReadonlySet<string>
rootCache: DirCache | undefined
@@ -54,6 +56,7 @@ export function useFileExplorerTree(
EMPTY_FILE_EXPLORER_LOADING_DIRS
)
const [rootError, setRootError] = useState<string | null>(null)
const [sourceWorkspaceId, setSourceWorkspaceId] = useState<string | null>(null)
const dirCacheRef = useRef(dirCache)
dirCacheRef.current = dirCache
// Why the ref is authoritative rather than a render mirror: writing it during render is unsafe
@@ -108,6 +111,7 @@ export function useFileExplorerTree(
}
if (depth === -1) {
setRootError(null)
setSourceWorkspaceId(activeWorktreeId?.trim() || null)
}
const children = fileExplorerEntriesToTreeNodes(
listing.entries,
@@ -132,6 +136,7 @@ export function useFileExplorerTree(
// empty worktree. Preserve the message so the UI can distinguish
// "no files" from "could not read this worktree".
setRootError(error instanceof Error ? error.message : String(error))
setSourceWorkspaceId(null)
rootReadFailedRef.current = true
}
setDirCache((prev) => ({ ...prev, [dirPath]: { children: [] } }))
@@ -267,6 +272,7 @@ export function useFileExplorerTree(
dirLoadTrackerRef.current.reset()
staleDirsRef.current.clear()
setDirCache({})
setSourceWorkspaceId(null)
updateLoadingDirPaths(() => EMPTY_FILE_EXPLORER_LOADING_DIRS)
setRootError(null)
if (worktreePath) {
@@ -277,6 +283,7 @@ export function useFileExplorerTree(
return {
dirCache,
setDirCache,
sourceWorkspaceId,
loadingDirPaths,
rootCache,
rootError,
@@ -114,4 +114,18 @@ describe('shouldUploadRemoteEditorFileDrop', () => {
})
expect(JSON.stringify(message)).not.toContain('secret')
})
it('names the drop whose file items carried no readable path (#15782)', () => {
expect(
getNativeFileDropRejectionMessage({
byteLength: 0,
pathCount: 2,
reason: 'unresolved-paths',
target: 'rejected'
})
).toEqual({
description: 'Save them to disk first, then drop the saved files.',
title: "Orca couldn't read a path for the dropped files."
})
})
})
@@ -207,6 +207,19 @@ export function getNativeFileDropRejectionMessage(data: NativeFileDropRejectedPa
description: string
title: string
} {
if (data.reason === 'unresolved-paths') {
return {
description: translate(
'auto.hooks.useGlobalFileDrop.nativeDropUnresolvedPathsDescription',
'Save them to disk first, then drop the saved files.'
),
title: translate(
'auto.hooks.useGlobalFileDrop.nativeDropUnresolvedPaths',
"Orca couldn't read a path for the dropped files."
)
}
}
if (data.reason === 'too-many-paths') {
return {
description: translate(
+7 -2
View File
@@ -1007,7 +1007,9 @@
"nativeDropTooManyPaths": "Drop contains too many files.",
"nativeDropPathsTooLargeDescription": "Drop fewer files or use a shorter path list.",
"nativeDropPathsTooLarge": "Drop path list is too large.",
"ownerChanged": "Couldn't verify which host owns this workspace. Try again after it reconnects."
"ownerChanged": "Couldn't verify which host owns this workspace. Try again after it reconnects.",
"nativeDropUnresolvedPathsDescription": "Save them to disk first, then drop the saved files.",
"nativeDropUnresolvedPaths": "Orca couldn't read a path for the dropped files."
},
"useIpcEvents": {
"0e3cf53060": "Browser tab {{value0}} not found",
@@ -17112,7 +17114,10 @@
"pendingAttachmentLimit": "Too many attachments are waiting. Finish composing before attaching more.",
"viewAttachment": "View image",
"imagePreview": "Full-size image preview",
"imagePreviewUnavailable": "Preview unavailable"
"imagePreviewUnavailable": "Preview unavailable",
"workspaceAttachmentMismatch": "Files can only be attached to their source workspace.",
"attachmentOwnerChanged": "This workspace changed hosts while attaching — drop the files again.",
"attachmentUnreadable": "Couldn't read the dropped files."
},
"tool": {
"exitCode": "exit {{value0}}",
@@ -0,0 +1,16 @@
import { useAppStore } from '@/store'
import { getExecutionHostIdForWorktree } from './worktree-runtime-owner'
import { writeWorkspaceFileDragSourceIfResolved } from './workspace-file-drag'
/** Source-control rows list the live workspace, so the owner is resolved now
* rather than captured with the listing (unlike the explorer's cached tree). */
export function writeWorkspaceFileDragSourceForWorkspace(
dataTransfer: Pick<DataTransfer, 'setData'>,
workspaceId: string
): void {
writeWorkspaceFileDragSourceIfResolved(
dataTransfer,
workspaceId,
getExecutionHostIdForWorktree(useAppStore.getState(), workspaceId)
)
}
@@ -5,7 +5,10 @@ import {
WORKSPACE_FILE_PATHS_MIME,
WORKSPACE_FILE_PATH_MIME,
encodeWorkspaceFilePaths,
readWorkspaceFileDragPaths
readWorkspaceFileDragPaths,
readWorkspaceFileDragSource,
isResolvedWorkspaceFileDragExecutionHost,
writeWorkspaceFileDragSource
} from './workspace-file-drag'
vi.mock('../../../shared/cross-platform-path', async (importOriginal) => {
@@ -29,6 +32,31 @@ class FakeDataTransfer {
}
describe('workspace file drag payloads', () => {
it('round-trips explicit workspace and execution-host ownership', () => {
const transfer = new FakeDataTransfer()
writeWorkspaceFileDragSource(transfer, {
executionHostId: 'ssh:remote-1',
workspaceId: 'folder:docs'
})
expect(readWorkspaceFileDragSource(transfer)).toEqual({
executionHostId: 'ssh:remote-1',
workspaceId: 'folder:docs'
})
})
it('rejects missing and malformed workspace ownership', () => {
const transfer = new FakeDataTransfer()
expect(readWorkspaceFileDragSource(transfer)).toBeNull()
transfer.setData('application/x-orca-workspace-file-source', '{"workspaceId":"worktree-1"}')
expect(readWorkspaceFileDragSource(transfer)).toBeNull()
})
it('rejects only the exact unresolved execution-host sentinel', () => {
expect(isResolvedWorkspaceFileDragExecutionHost('runtime:unresolved-owner')).toBe(false)
expect(isResolvedWorkspaceFileDragExecutionHost('runtime:my-unresolved-owner-env')).toBe(true)
})
it('round-trips bounded multi-path payloads and removes nested duplicates', () => {
const transfer = new FakeDataTransfer()
transfer.setData(
@@ -5,9 +5,24 @@ import {
validateNativeFileDropPaths
} from '../../../shared/native-file-drop'
import { measureClipboardTextByteLength } from '../../../shared/clipboard-text'
import { normalizeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
export const WORKSPACE_FILE_PATH_MIME = 'text/x-orca-file-path'
export const WORKSPACE_FILE_PATHS_MIME = 'text/x-orca-file-paths'
export const WORKSPACE_FILE_DRAG_SOURCE_MIME = 'application/x-orca-workspace-file-source'
const WORKSPACE_FILE_DRAG_SOURCE_MAX_BYTES = 4096
export type WorkspaceFileDragSource = {
executionHostId: ExecutionHostId
workspaceId: string
}
export function isResolvedWorkspaceFileDragExecutionHost(
executionHostId: ExecutionHostId
): boolean {
return executionHostId !== 'runtime:unresolved-owner'
}
export type WorkspaceFileDragRejectionReason = 'paths-too-large' | 'too-many-paths'
@@ -33,6 +48,71 @@ export function encodeWorkspaceFilePaths(paths: readonly string[]): string {
return paths.length === 1 ? paths[0] : JSON.stringify(paths)
}
export function writeWorkspaceFileDragSource(
dataTransfer: Pick<DataTransfer, 'setData'>,
source: WorkspaceFileDragSource
): void {
dataTransfer.setData(WORKSPACE_FILE_DRAG_SOURCE_MIME, JSON.stringify({ ...source, version: 1 }))
}
/** Stamp only when both halves resolve: an unstamped drag fails closed at the
* composer, which is the right answer for an owner we could not name. */
export function writeWorkspaceFileDragSourceIfResolved(
dataTransfer: Pick<DataTransfer, 'setData'>,
workspaceId: string | null | undefined,
executionHostId: ExecutionHostId | null | undefined
): void {
if (
!workspaceId ||
!executionHostId ||
!isResolvedWorkspaceFileDragExecutionHost(executionHostId)
) {
return
}
writeWorkspaceFileDragSource(dataTransfer, { executionHostId, workspaceId })
}
export function readWorkspaceFileDragSource(
dataTransfer: Pick<DataTransfer, 'getData'>
): WorkspaceFileDragSource | null {
const data = dataTransfer.getData(WORKSPACE_FILE_DRAG_SOURCE_MIME)
if (!data) {
return null
}
if (
measureClipboardTextByteLength(data, {
stopAfterBytes: WORKSPACE_FILE_DRAG_SOURCE_MAX_BYTES
}).exceededLimit
) {
return null
}
try {
const parsed: unknown = JSON.parse(data)
if (!parsed || typeof parsed !== 'object') {
return null
}
const executionHostValue = 'executionHostId' in parsed ? parsed.executionHostId : null
const executionHostId =
typeof executionHostValue === 'string' ? normalizeExecutionHostId(executionHostValue) : null
const workspaceValue = 'workspaceId' in parsed ? parsed.workspaceId : null
const workspaceId = typeof workspaceValue === 'string' ? workspaceValue.trim() : ''
const version = 'version' in parsed ? parsed.version : null
if (version !== 1 || !executionHostId || !workspaceId) {
return null
}
return { executionHostId, workspaceId }
} catch {
return null
}
}
export function hasWorkspaceFileDragType(dataTransfer: Pick<DataTransfer, 'types'>): boolean {
return (
dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME) ||
dataTransfer.types.includes(WORKSPACE_FILE_PATHS_MIME)
)
}
export function decodeWorkspaceFilePaths(data: string): string[] {
const result = decodeWorkspaceFilePathPayload(data)
return result.status === 'accepted' ? result.paths : []
+12 -3
View File
@@ -41,10 +41,17 @@ export type NativeFileDropPayload =
export type NativeFileDropRejectedPayload = {
byteLength: number
pathCount: number
reason: 'paths-too-large' | 'too-many-paths'
reason: NativeFileDropRejectionReason
target: 'rejected'
}
/** What path validation alone can reject a drop for. */
export type NativeFileDropSizeRejectionReason = 'paths-too-large' | 'too-many-paths'
/** `unresolved-paths`: the OS handed us file items no path could be read from
* (promised/virtual files), which used to be swallowed with no feedback. */
export type NativeFileDropRejectionReason = NativeFileDropSizeRejectionReason | 'unresolved-paths'
export type NativeFileDropPathEntry = {
nativeFileDropTarget?: string
nativeFileDropDir?: string
@@ -58,14 +65,16 @@ export type NativeFileDropPathValidation =
| {
byteLength: number
pathCount: number
reason: NativeFileDropRejectedPayload['reason']
reason: NativeFileDropSizeRejectionReason
status: 'rejected'
}
function isNativeFileDropRejectedReason(
reason: unknown
): reason is NativeFileDropRejectedPayload['reason'] {
return reason === 'paths-too-large' || reason === 'too-many-paths'
return (
reason === 'paths-too-large' || reason === 'too-many-paths' || reason === 'unresolved-paths'
)
}
function isNativeFileDropTarget(target: unknown): target is NativeFileDropPayload['target'] {