Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
80f0623
feat(file-drop): add element owner preparation plumbing
brennanb2025 Oct 6, 2026
2dcc2a9
Fix terminal and chat file drop destination ownership
brennanb2025 Oct 6, 2026
a0a5923
fix runtime terminal drop ownership and queued chat retries
brennanb2025 Oct 6, 2026
2e3f19a
fix(file-drop): preserve feedback acceptance and destination ordering
brennanb2025 Oct 6, 2026
1146abc
Merge branch 'brennanb2025/sta-6940-pr1-drop-ownership' into brennanb…
brennanb2025 Oct 6, 2026
015f710
fix(file-drop): attach chat and composer files at the drop surface
brennanb2025 Oct 6, 2026
036f0db
fix(file-drop): preserve project destinations and live chat availability
brennanb2025 Oct 6, 2026
7f5a703
fix(file-drop): keep path resolution in filesystem namespace
brennanb2025 Oct 6, 2026
3596fb7
Merge remote-tracking branch 'origin/brennanb2025/sta-6940-pr2-drop-p…
brennanb2025 Oct 6, 2026
e3b891b
test(file-drop): use filesystem path bridge in PR3 fixtures
brennanb2025 Oct 6, 2026
8391981
Merge origin/main into STA-6940 PR2
brennanb2025 Oct 6, 2026
be26b1b
Merge remote-tracking branch 'origin/main' into brennanb2025/sta-6940…
brennanb2025 Oct 7, 2026
15d5b3f
Merge updated STA-6940 PR2 and main into PR3
brennanb2025 Oct 7, 2026
3dde5f1
test(file-drop): align composer lifecycle with element-owned drops
brennanb2025 Oct 7, 2026
ff58070
Merge origin/main into STA-6940 PR3
brennanb2025 Oct 7, 2026
ad6d715
Merge origin/main into STA-6940 chat drop migration
brennanb2025 Oct 7, 2026
2c39f25
Fix interrupted chat composition and refuse unused quick-create drops
brennanb2025 Oct 7, 2026
c62129a
Type the quick-create drop regression transfer
brennanb2025 Oct 7, 2026
c4825e8
Merge origin/main into STA-6940 PR3 chat drop migration
brennanb2025 Oct 7, 2026
0c95de3
Merge origin/main (main typecheck fix #26097) into STA-6940 PR3
brennanb2025 Oct 7, 2026
89ffd1c
Merge origin/main into STA-6940 chat drop migration
brennanb2025 Oct 7, 2026
e0e04d7
Merge current main into STA-6940 chat drop migration
brennanb2025 Oct 7, 2026
91fc850
Merge origin/main into STA-6940 chat drop migration
brennanb2025 Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
// @vitest-environment happy-dom
import type * as ReactI18nextModule from 'react-i18next'
import { act, renderHook } from '@testing-library/react'
import { useNewWorkspaceComposerFileDrop } from './new-workspace/use-new-workspace-composer-file-drop'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { renderCard, unmountCard } from './NewWorkspaceComposerCard.test-fixture'
import { createRef } from 'react'
import { resolveComposerAttachmentTarget } from '../hooks/composer-state/composer-attachment-target'
import { useAttachmentDropState } from '../hooks/composer-state/attachment-drop-state'
import type { ProjectGroup } from '../../../shared/project-group-types'

const uploads = vi.hoisted(() => ({ importPaths: vi.fn() }))
vi.mock('@/runtime/runtime-file-client', () => ({
importExternalPathsToRuntime: uploads.importPaths
}))

vi.mock('@/store', () => ({
useAppStore: Object.assign(
(selector: (state: unknown) => unknown) => selector({ settings: {}, projects: [], repos: [] }),
{
getState: () => ({
sshConnectionStates: connections,
sshStateByEnvironment: runtimeConnections
})
}
)
}))
vi.mock('react-i18next', async (importOriginal) => ({
...(await importOriginal<typeof ReactI18nextModule>()),
useTranslation: () => ({})
}))
vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({
useContextualTour: () => {}
}))
vi.mock('@/components/sidebar/AddRemoteHostDialog', () => ({ AddRemoteHostDialog: () => null }))
vi.mock('./new-workspace/NewWorkspaceComposerProjectSection', () => ({
NewWorkspaceComposerProjectSection: () => null
}))
vi.mock('./new-workspace/NewWorkspaceComposerNameSection', () => ({
NewWorkspaceComposerNameSection: () => null
}))
vi.mock('./new-workspace/NewWorkspaceComposerAgentSection', () => ({
NewWorkspaceComposerAgentSection: () => null
}))
vi.mock('./new-workspace/NewWorkspaceComposerAdvancedSection', () => ({
NewWorkspaceComposerAdvancedSection: () => null
}))
vi.mock('./new-workspace/NewWorkspaceComposerFooter', () => ({
NewWorkspaceComposerFooter: () => <textarea />
}))

const runtimeConnections = vi.hoisted(
() => new Map<string, { connectionStates: Map<string, { connectionGeneration: number }> }>()
)
const connections = vi.hoisted(() => new Map<string, { connectionGeneration: number }>())
const prepare = vi.fn(async ({ paths }: { paths: string[] }) => ({ paths, failures: [] }))
const cards: HTMLDivElement[] = []
beforeEach(() => {
vi.clearAllMocks()
connections.clear()
runtimeConnections.clear()
uploads.importPaths.mockResolvedValue({
results: [
{
status: 'imported',
destPath: '/folder/.orca/drops/a.txt',
kind: 'file'
}
]
})
vi.stubGlobal('api', {
fs: {
getPathForFile: (file: File) => `/drop/${file.name}`,
prepareDroppedPaths: prepare
}
})
})
afterEach(() => {
for (const card of cards.splice(0)) {
unmountCard(card)
}
vi.unstubAllGlobals()
})
async function card(
attach: (paths: string[], current: () => boolean) => Promise<void>,
path = '/repo'
) {
const container = await renderCard({
selectedRepoPath: path,
selectedRepoExecutionHostId: 'local',
onNativeFileDrop: attach,
projectHostSetupOptions: []
})
cards.push(container)
return container
}
async function drop(container: HTMLDivElement) {
const event = new Event('drop', { bubbles: true, cancelable: true, composed: true })
Object.defineProperty(event, 'isTrusted', { value: true })
Object.defineProperty(event, 'dataTransfer', {
value: { types: ['Files'], files: [new File(['x'], 'a.txt')] }
})
await act(async () => {
container.querySelector('textarea')!.dispatchEvent(event)
})
}
describe('new workspace card file drop ownership', () => {
it.each([
['/folder/source-repo', false],
['/outside/source-repo', false],
['/folder/source-repo', true],
['/outside/source-repo', true]
] as const)(
'uploads into the selected folder project with task source %s (changes during preparation: %s)',
async (sourcePath, changesSource) => {
const attach = vi.fn()
const group: ProjectGroup = {
id: 'folder-project',
name: 'Folder',
parentPath: '/folder',
connectionId: 'ssh-a',
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 0,
updatedAt: 0
}
connections.set('ssh-a', { connectionGeneration: 1 })
const initialProps: { source: string } = { source: sourcePath }
const hook = renderHook(
({ source }: { source: string }) => {
const target = resolveComposerAttachmentTarget({
selectedProjectGroup: group,
selectedRepoPath: source,
selectedRepoExecutionHostId: 'ssh:ssh-a',
selectedRepoSettings: {},
connectionId: 'ssh-a'
})
const dropState = useAttachmentDropState({
agentPromptRef: { current: '' },
cancelPromptCaretFrame: () => {},
promptCaretFrameRef: { current: null },
promptTextareaRef: createRef<HTMLTextAreaElement>(),
connectionId: target.connectionId,
selectedRepoPath: target.path ?? undefined,
selectedRepoSettings: target.settings,
setAgentPrompt: () => {},
setAttachmentPaths: attach
})
return useNewWorkspaceComposerFileDrop({
projectPath: target.path,
hostId: target.hostId,
connectionId: target.connectionId,
applyDrop: dropState.applyNativeDrop
})
},
{ initialProps }
)
const owner = document.createElement('div')
const textarea = document.createElement('textarea')
owner.append(textarea)
document.body.append(owner)
act(() => hook.result.current(owner))
try {
const gate = Promise.withResolvers<{ paths: string[]; failures: never[] }>()
prepare.mockImplementationOnce(() => gate.promise)
await drop(owner)
if (changesSource) {
hook.rerender({ source: '/different-task-source' })
}
await act(async () => gate.resolve({ paths: ['/drop/a.txt'], failures: [] }))
expect(uploads.importPaths).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ worktreePath: '/folder', connectionId: 'ssh-a' }),
['/drop/a.txt'],
'/folder/.orca/drops',
expect.any(Object)
)
expect(attach).toHaveBeenCalledOnce()
} finally {
act(() => hook.result.current(null))
hook.unmount()
owner.remove()
}
}
)
it('delivers to the card under the cursor even when another card mounts later', async () => {
const first = vi.fn(async () => {})
const second = vi.fn(async () => {})
const a = await card(first, '/repo-a')
const b = await card(second, '/repo-b')
await drop(a)
expect(first).toHaveBeenCalledExactlyOnceWith(['/drop/a.txt'], expect.any(Function))
expect(second).not.toHaveBeenCalled()
await drop(b)
expect(second).toHaveBeenCalledExactlyOnceWith(['/drop/a.txt'], expect.any(Function))
unmountCard(b)
cards.splice(cards.indexOf(b), 1)
await drop(a)
expect(first).toHaveBeenCalledTimes(2)
expect(a.querySelector('[data-native-file-drop-target]')).toBeNull()
})
it('keeps a preparation captured for the original card when another card mounts', async () => {
const gate = Promise.withResolvers<{ paths: string[]; failures: never[] }>()
prepare.mockImplementationOnce(() => gate.promise)
const first = vi.fn(async () => {})
const second = vi.fn(async () => {})
const a = await card(first)
await drop(a)
await card(second)
await act(async () => gate.resolve({ paths: ['/prepared/a.txt'], failures: [] }))
expect(first).toHaveBeenCalledExactlyOnceWith(['/prepared/a.txt'], expect.any(Function))
expect(second).not.toHaveBeenCalled()
})
it('abandons a preparation when its card unmounts', async () => {
const gate = Promise.withResolvers<{ paths: string[]; failures: never[] }>()
prepare.mockImplementationOnce(() => gate.promise)
const attach = vi.fn(async () => {})
const a = await card(attach)
await drop(a)
unmountCard(a)
cards.splice(cards.indexOf(a), 1)
await act(async () => gate.resolve({ paths: ['/prepared/a.txt'], failures: [] }))
expect(attach).not.toHaveBeenCalled()
})
it.each(['project', 'host', 'connection', 'runtime connection'] as const)(
'refuses a captured destination after its %s changes',
async (change) => {
const gate = Promise.withResolvers<{ paths: string[]; failures: never[] }>()
prepare.mockImplementationOnce(() => gate.promise)
const applyDrop = vi.fn(async () => {})
connections.set('ssh-a', { connectionGeneration: 1 })
runtimeConnections.set('runtime-a', {
connectionStates: new Map([['ssh-a', { connectionGeneration: 1 }]])
})
const input: Parameters<typeof useNewWorkspaceComposerFileDrop>[0] = {
projectPath: '/repo',
hostId: change === 'runtime connection' ? 'runtime:runtime-a' : 'ssh:ssh-a',
connectionId: 'ssh-a',
applyDrop
}
const hook = renderHook(
(args: Parameters<typeof useNewWorkspaceComposerFileDrop>[0]) =>
useNewWorkspaceComposerFileDrop(args),
{ initialProps: input }
)
const owner = document.createElement('div')
const target = document.createElement('textarea')
owner.append(target)
document.body.append(owner)
act(() => hook.result.current(owner))
const event = new Event('drop', { bubbles: true, cancelable: true, composed: true })
Object.defineProperty(event, 'isTrusted', { value: true })
Object.defineProperty(event, 'dataTransfer', {
value: { types: ['Files'], files: [new File(['x'], 'a.txt')] }
})
await act(async () => target.dispatchEvent(event))
if (change === 'runtime connection') {
runtimeConnections.set('runtime-a', {
connectionStates: new Map([['ssh-a', { connectionGeneration: 2 }]])
})
} else if (change === 'connection') {
connections.set('ssh-a', { connectionGeneration: 2 })
} else {
hook.rerender({
...input,
projectPath: change === 'project' ? '/other' : input.projectPath,
hostId: change === 'host' ? 'local' : input.hostId
})
}
await act(async () => gate.resolve({ paths: ['/prepared/a.txt'], failures: [] }))
expect(applyDrop).not.toHaveBeenCalled()
act(() => hook.result.current(null))
hook.unmount()
owner.remove()
}
)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// @vitest-environment happy-dom
import { act, cleanup, render, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { useComposerState } from '@/hooks/useComposerState'
import NewWorkspaceComposerCard from './NewWorkspaceComposerCard'
import { TooltipProvider } from '@/components/ui/tooltip'
import type * as I18nModule from '@/i18n/i18n'
import type * as ReactI18nextModule from 'react-i18next'

vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({
useContextualTour: () => {}
}))
vi.mock('@/i18n/i18n', async (importOriginal) => ({
...(await importOriginal<typeof I18nModule>()),
translate: (_key: string, fallback: string) => fallback
}))
vi.mock('react-i18next', async (importOriginal) => ({
...(await importOriginal<typeof ReactI18nextModule>()),
useTranslation: () => ({})
}))

const prepare = vi.fn(async ({ paths }: { paths: string[] }) => ({ paths, failures: [] }))
const stat = vi.fn(async () => ({ isDirectory: false }))
const onFileDrop = vi.fn<Window['api']['ui']['onFileDrop']>(() => () => {})
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('api', {
fs: {
getPathForFile: (file: File) => `/drop/${file.name}`,
prepareDroppedPaths: prepare,
stat
},
ui: { onFileDrop },
gh: {
repoSlug: vi.fn(async () => null),
listWorkItems: vi.fn(async () => ({ items: [], hasMore: false }))
},
sparsePresets: { list: vi.fn(async () => []) },
worktrees: { listRetiredNames: vi.fn(async () => ({ names: [], exhaustedTiers: 0 })) },
preflight: { detectAgents: vi.fn(async () => []) }
})
useAppStore.setState({
repos: [
{
id: 'local-project',
path: '/project',
displayName: 'Project',
badgeColor: 'var(--muted-foreground)',
addedAt: 0
}
],
activeRepoId: 'local-project',
activeModal: 'new-workspace-composer',
projects: [],
projectGroups: [],
projectHostSetups: [],
newWorkspaceDraft: null,
worktreesByRepo: {},
sparsePresetsByRepo: {}
})
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})

type FileDragTransfer = { types: string[]; files: File[]; dropEffect: string }

function gesture(target: Element, type: string, transfer: FileDragTransfer) {
const event = new Event(type, { bubbles: true, cancelable: true, composed: true })
Object.defineProperty(event, 'isTrusted', { value: true })
Object.defineProperty(event, 'dataTransfer', { value: transfer })
target.dispatchEvent(event)
}

describe('quick-create modal card without an attachment destination', () => {
it('refuses an OS drop with the selected local project and does not show an accepting ring', async () => {
const hook = renderHook(() =>
useComposerState({
initialRepoId: 'local-project',
persistDraft: false
})
)
expect(hook.result.current.cardProps.selectedRepoPath).toBe('/project')
expect(hook.result.current.cardProps.selectedRepoExecutionHostId).toBe('local')
const { container } = render(
<TooltipProvider>
<NewWorkspaceComposerCard
{...hook.result.current.cardProps}
composerRef={hook.result.current.composerRef}
onComposerNodeChange={hook.result.current.onComposerNodeChange}
onCreate={() => {}}
primaryActionLabel="Create workspace"
quickAgent={null}
onQuickAgentChange={() => {}}
/>
</TooltipProvider>
)
const card = container.querySelector('[data-workspace-composer-root]')!
expect(container.querySelector('textarea')?.getAttribute('placeholder')).toBe('Write a note')
expect(container.querySelector('[data-attachment-path]')).toBeNull()
const transfer = { types: ['Files'], files: [new File(['x'], 'notes.txt')], dropEffect: 'link' }
await act(async () => {
gesture(card, 'dragenter', transfer)
gesture(card, 'dragover', transfer)
})
expect(transfer.dropEffect).toBe('none')
expect(card.className).not.toContain('ring-2')
await act(async () => gesture(card, 'drop', transfer))
expect(prepare).not.toHaveBeenCalled()
expect(stat).not.toHaveBeenCalled()
expect(hook.result.current.cardProps.attachmentPaths).toEqual([])
})
})
Loading
Loading