Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 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
d844eb9
fix(file-drop): bind terminal drops to pane elements
brennanb2025 Oct 6, 2026
fd67741
fix(file-drop): compare terminal pane identity across public views
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
fbb9334
Merge final STA-6940 chat and composer stack into terminal owners
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
080123c
Merge remote-tracking branch 'origin/brennanb2025/sta-6940-pr3-chat-c…
brennanb2025 Oct 7, 2026
e9f07e7
Merge origin/main into STA-6940 PR4
brennanb2025 Oct 7, 2026
1e425ad
Merge origin/main into STA-6940 PR4
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
eda595d
Merge STA-6940 PR3 (0c95de36943) into PR4
brennanb2025 Oct 7, 2026
b4aa7f4
Merge origin/main (4077fb4a8de) into STA-6940 PR4
brennanb2025 Oct 7, 2026
ae8d3ab
fix(file-drop): let the explorer, project sidebar, tab strip and edit…
brennanb2025 Oct 7, 2026
e767be6
fix(file-drop): keep editor delivery tied to its destination group
brennanb2025 Oct 7, 2026
5ed9268
fix(file-drop): guard delayed opens and share floating ownership
brennanb2025 Oct 7, 2026
2ef4f37
Merge origin/main (939288e51d3) into STA-6940 PR5
brennanb2025 Oct 8, 2026
b1b93fb
fix(file-drop): remove legacy OS drop routing (STA-6940 PR6/6)
brennanb2025 Oct 8, 2026
9f96644
test(file-drop): finish owner test and comment cleanup
brennanb2025 Oct 8, 2026
158474b
Clarify dropped-file default editor destination
brennanb2025 Oct 8, 2026
916bd82
Merge origin/main into STA-6940 legacy drop cleanup
brennanb2025 Oct 8, 2026
f1e8f19
Retain UI test namespace for terminal resume coverage
brennanb2025 Oct 8, 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
2 changes: 0 additions & 2 deletions config/scripts/ci-shard-timings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7407,7 +7407,6 @@
"src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx": 587,
"src/renderer/src/components/terminal-pane/use-system-prefers-dark.test.ts": 45,
"src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-active-pty-reporting.test.ts": 1301,
"src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-file-drop.test.ts": 1219,
"src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-paste-events.test.ts": 1149,
"src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-sync-fit-registration.test.ts": 1211,
"src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts": 1904,
Expand Down Expand Up @@ -7632,7 +7631,6 @@
"src/renderer/src/hooks/useEditorExternalWatch.test.ts": 492,
"src/renderer/src/hooks/useEphemeralVmRecipeOptions.test.tsx": 420,
"src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx": 556,
"src/renderer/src/hooks/useGlobalFileDrop.test.ts": 1582,
"src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx": 6914,
"src/renderer/src/hooks/useInstalledAgentSkills.test.ts": 1496,
"src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts": 50,
Expand Down
75 changes: 0 additions & 75 deletions src/main/window/attach-main-window-services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,81 +628,6 @@ describe('attachMainWindowServices', () => {
expect(browserManagerUnregisterAllMock).toHaveBeenCalledTimes(1)
})

it('removes the native file-drop relay when the main window closes', () => {
const mainWindowOnMock = vi.fn()
const mainWindow = createMainWindow({ send: vi.fn() })
mainWindow.on = mainWindowOnMock

attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never)

const channel = 'terminal:file-dropped-from-preload'
const relayHandler = onMock.mock.calls.find(([event]) => event === channel)?.[1]
expect(relayHandler).toBeTypeOf('function')
expect(removeAllListenersMock).toHaveBeenCalledWith(channel)

const closedHandlers = getClosedHandlers(mainWindowOnMock)
for (const handler of closedHandlers) {
handler()
}

expect(removeListenerMock).toHaveBeenCalledWith(channel, relayHandler)
})

it('relays native file drops only from the owning renderer webContents', () => {
const sendMock = vi.fn()
const mainWindow = createMainWindow({ send: sendMock })

attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never)

const channel = 'terminal:file-dropped-from-preload'
const relayHandler = onMock.mock.calls.find(([event]) => event === channel)?.[1]
const payload = { paths: ['/tmp/a'], target: 'editor' }

relayHandler?.({ sender: { id: 999 } }, payload)

expect(sendMock).not.toHaveBeenCalled()

relayHandler?.({ sender: mainWindow.webContents }, payload)

expect(sendMock).toHaveBeenCalledWith('terminal:file-drop', payload)
})

it('ignores malformed native file-drop payloads from the owning renderer', () => {
const sendMock = vi.fn()
const mainWindow = createMainWindow({ send: sendMock })

attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never)

const channel = 'terminal:file-dropped-from-preload'
const relayHandler = onMock.mock.calls.find(([event]) => event === channel)?.[1]

relayHandler?.(
{ sender: mainWindow.webContents },
{ paths: ['C:\\Users\\alice\\secret.txt'], target: 'browser' }
)
relayHandler?.(
{ sender: mainWindow.webContents },
{ paths: ['/tmp/a'], target: 'file-explorer' }
)

expect(sendMock).not.toHaveBeenCalled()
})

it('ignores native file drops after the owning webContents is destroyed', () => {
const sendMock = vi.fn()
const mainWindow = createMainWindow({ send: sendMock })

attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never)

const channel = 'terminal:file-dropped-from-preload'
const relayHandler = onMock.mock.calls.find(([event]) => event === channel)?.[1]
mainWindow.webContents.isDestroyed?.mockReturnValue(true)

relayHandler?.({ sender: mainWindow.webContents }, { paths: ['/tmp/a'], target: 'editor' })

expect(sendMock).not.toHaveBeenCalled()
})

it('clears the runtime notifier when the owning window closes', () => {
const mainWindowOnMock = vi.fn()
const mainWindow = createMainWindow()
Expand Down
4 changes: 2 additions & 2 deletions src/main/window/attach-main-window-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import {
import { startFolderRepoGitUpgradeWatch } from '../ipc/folder-repo-git-upgrade'
import { scheduleMainWindowAutoUpdaterSetup } from './main-window-updater'
import { registerRuntimeWindowLifecycle } from './runtime-window-lifecycle'
import { registerFileDropRelay } from './native-file-drop-relay'
import { registerDroppedPathPreparation } from './dropped-path-preparation-ipc'

export { ensureAutoUpdaterConfigured, registerUpdaterHandlers } from './main-window-updater'

Expand Down Expand Up @@ -126,7 +126,7 @@ export function attachMainWindowServices(
}
registerSshHandlers(store, () => mainWindow, runtime)
registerRemoteWorkspaceHandlers(store, () => mainWindow, runtime)
registerFileDropRelay(mainWindow)
registerDroppedPathPreparation(mainWindow)
registerTccPromptNoticeHandlers(mainWindow)
scheduleMainWindowAutoUpdaterSetup(mainWindow, store, options)
registerRuntimeWindowLifecycle(mainWindow, runtime)
Expand Down
192 changes: 192 additions & 0 deletions src/main/window/dropped-path-preparation-ipc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import type { BrowserWindow } from 'electron'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as DragTempFileCopy from './dragged-temp-file-copy'
import type { DragTempCopyItemResult } from './dragged-temp-file-copy'

type IpcListener = (event: { sender: unknown }, payload: unknown) => void
type InvokeHandler = (event: { sender: unknown }, payload: unknown) => Promise<unknown>

const { materializeMock, sweepMock, ipcListeners, ipcHandlers } = vi.hoisted(() => ({
materializeMock: vi.fn(),
sweepMock: vi.fn(),
ipcListeners: new Map<string, IpcListener>(),
ipcHandlers: new Map<string, InvokeHandler>()
}))

vi.mock('electron', () => ({
app: { getPath: () => '/app-temp' },
ipcMain: {
handle: (channel: string, handler: InvokeHandler) => ipcHandlers.set(channel, handler),
removeHandler: (channel: string) => ipcHandlers.delete(channel),
on: (channel: string, listener: IpcListener) => ipcListeners.set(channel, listener),
removeListener: (channel: string, listener: IpcListener) => {
if (ipcListeners.get(channel) === listener) {
ipcListeners.delete(channel)
}
},
removeAllListeners: (channel: string) => ipcListeners.delete(channel)
}
}))

vi.mock('./dragged-temp-file-copy', async (importOriginal) => ({
...(await importOriginal<typeof DragTempFileCopy>()),
materializeDragTempPaths: materializeMock,
scheduleDragTempCopySweep: sweepMock
}))

vi.mock('./darwin-user-temp-dir', () => ({
getDarwinUserTempDir: async () => '/private/var/folders/ab/xyz/T'
}))

import { registerDroppedPathPreparation } from './dropped-path-preparation-ipc'

const DRAG_TEMP = join('/', 'var', 'T', 'TemporaryItems', 'NSIRD_screencaptureui_1', 'Shot.png')
const COPY = join('/', 'var', 'T', 'orca-drops-501', 'orca-drop-abc123', 'Shot.png')
const FINDER = join('/', 'Users', 'me', 'Desktop', 'notes.txt')

function copied(sourcePath: string, destPath = sourcePath): DragTempCopyItemResult {
return { sourcePath, status: 'imported', destPath }
}

function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((settle) => {
resolve = settle
})
return { promise, resolve }
}

async function settle(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0))
}

beforeEach(() => {
materializeMock.mockReset()
})

function createWindow() {
let destroyed = false
const windowListeners = new Map<string, () => void>()
const webContents = {
isDestroyed: () => destroyed,
send: vi.fn(),
once: vi.fn<(event: string, listener: () => void) => void>(),
removeListener: vi.fn()
}
const fake = {
isDestroyed: () => destroyed,
on: (event: string, listener: () => void) => windowListeners.set(event, listener),
webContents
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the preparation handler only touches the members stubbed above.
const window = fake as unknown as BrowserWindow
return {
window,
webContents,
close: () => {
destroyed = true
windowListeners.get('closed')?.()
},
destroy: () => {
destroyed = true
webContents.once.mock.calls.find(([event]) => event === 'destroyed')?.[1]()
}
}
}

describe('registerDroppedPathPreparation', () => {
beforeEach(() => {
ipcListeners.clear()
ipcHandlers.clear()
sweepMock.mockReset()
})

it('registers only the preparation request and starts copy expiry', () => {
const { window } = createWindow()
registerDroppedPathPreparation(window)
expect([...ipcHandlers.keys()]).toEqual(['fs:prepareDroppedPaths'])
expect(ipcListeners.size).toBe(0)
expect(sweepMock).toHaveBeenCalledOnce()
})

it('prepares ordinary paths only for its own live renderer and validates the request', async () => {
const { window, webContents, close } = createWindow()
registerDroppedPathPreparation(window)
const handler = ipcHandlers.get('fs:prepareDroppedPaths')!
const request = { paths: [FINDER], consumer: 'agent' }
expect(() => handler({ sender: {} }, request)).toThrow('owning window')
for (const invalid of [
null,
{ paths: [FINDER], consumer: 'other' },
{ paths: [42], consumer: 'agent' }
]) {
expect(() => handler({ sender: webContents }, invalid)).toThrow('Invalid')
}
await expect(handler({ sender: webContents }, request)).resolves.toEqual({
paths: [FINDER],
failures: []
})
expect(webContents.send).not.toHaveBeenCalled()
close()
expect(ipcHandlers.has('fs:prepareDroppedPaths')).toBe(false)
expect(() => handler({ sender: webContents }, request)).toThrow('owning window')
})

it('enforces both request caps before preparing any paths', async () => {
const { window, webContents } = createWindow()
registerDroppedPathPreparation(window)
const handler = ipcHandlers.get('fs:prepareDroppedPaths')!
await expect(
handler(
{ sender: webContents },
{ paths: Array.from({ length: 257 }, () => DRAG_TEMP), consumer: 'agent' }
)
).resolves.toMatchObject({
paths: [],
failures: [{ reason: 'too-many-paths', pathCount: 257 }]
})
await expect(
handler({ sender: webContents }, { paths: ['x'.repeat(256 * 1024 + 1)], consumer: 'agent' })
).resolves.toMatchObject({ paths: [], failures: [{ reason: 'paths-too-large' }] })
expect(materializeMock).not.toHaveBeenCalled()
})

it('keeps the replacement window handler when an older window closes late', async () => {
const first = createWindow()
const second = createWindow()
registerDroppedPathPreparation(first.window)
registerDroppedPathPreparation(second.window)
const handler = ipcHandlers.get('fs:prepareDroppedPaths')!
first.close()
expect(ipcHandlers.get('fs:prepareDroppedPaths')).toBe(handler)
await expect(
handler({ sender: second.webContents }, { paths: [FINDER], consumer: 'main-reader' })
).resolves.toEqual({ paths: [FINDER], failures: [] })
})

it.skipIf(process.platform !== 'darwin')(
'copies from the macOS user temp dir and aborts when the renderer is gone',
async () => {
const copy = deferred<DragTempCopyItemResult[]>()
materializeMock.mockReturnValueOnce(copy.promise)
const { window, webContents, destroy } = createWindow()
registerDroppedPathPreparation(window)
const handler = ipcHandlers.get('fs:prepareDroppedPaths')!
const pending = handler({ sender: webContents }, { paths: [DRAG_TEMP], consumer: 'agent' })
await settle()
expect(materializeMock.mock.calls[0][1]).toMatchObject({
platform: 'darwin',
sourceTempRoot: '/private/var/folders/ab/xyz/T'
})
const signal = materializeMock.mock.calls[0][2]
expect(signal).toBeInstanceOf(AbortSignal)
const rejected = expect(pending).rejects.toThrow('went away')
destroy()
copy.resolve([copied(DRAG_TEMP, COPY)])
await rejected
expect(signal.aborted).toBe(true)
expect(webContents.send).not.toHaveBeenCalled()
}
)
})
78 changes: 78 additions & 0 deletions src/main/window/dropped-path-preparation-ipc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { app, ipcMain, type BrowserWindow } from 'electron'
import {
createRejectedNativeFileDropPayload,
validateNativeFileDropPaths
} from '../../shared/native-file-drop'
import type {
PrepareDroppedPathsRequest,
PreparedDroppedPaths
} from '../../shared/native-file-drop-preparation'
import { abortWhenRendererGone } from '../ipc/renderer-lifetime-abort'
import { getDragTempCopyRoot, scheduleDragTempCopySweep } from './dragged-temp-file-copy'
import {
createDroppedPathPreparationQueue,
windowDragTempCopyLane
} from './dropped-path-preparation'
import { getDarwinUserTempDir } from './darwin-user-temp-dir'

export function registerDroppedPathPreparation(mainWindow: BrowserWindow): void {
const prepareChannel = 'fs:prepareDroppedPaths'
const mainWebContents = mainWindow.webContents
const isWindowGone = (): boolean => mainWindow.isDestroyed() || mainWebContents.isDestroyed()
ipcMain.removeHandler(prepareChannel)
const prepare = createDroppedPathPreparationQueue(
{
platform: process.platform,
getCopyEnvironment: async () => ({
platform: process.platform,
sourceTempRoot: await getDarwinUserTempDir(),
copyRoot: getDragTempCopyRoot(app.getPath('temp'))
}),
watchRenderer: () => abortWhenRendererGone(mainWebContents)
},
windowDragTempCopyLane
)
const prepareHandler = (
event: Electron.IpcMainInvokeEvent,
args: unknown
): Promise<PreparedDroppedPaths> => {
if (isWindowGone() || event.sender !== mainWebContents) {
throw new Error('Dropped paths must come from the owning window')
}
if (!isPrepareDroppedPathsRequest(args)) {
throw new Error('Invalid dropped paths request')
}
const validation = validateNativeFileDropPaths(args.paths)
if (validation.status === 'rejected') {
return Promise.resolve({
paths: [],
failures: [createRejectedNativeFileDropPayload(validation)]
})
}
return Promise.resolve(prepare(args))
}
ipcMain.handle(prepareChannel, prepareHandler)
activePrepareHandler = prepareHandler
mainWindow.on('closed', () => {
// Why: an old window closing late must not remove the replacement window's handler.
if (activePrepareHandler === prepareHandler) {
ipcMain.removeHandler(prepareChannel)
activePrepareHandler = undefined
}
})
scheduleDragTempCopySweep(() => getDragTempCopyRoot(app.getPath('temp')))
}

let activePrepareHandler: unknown

function isPrepareDroppedPathsRequest(value: unknown): value is PrepareDroppedPathsRequest {
return (
!!value &&
typeof value === 'object' &&
'consumer' in value &&
(value.consumer === 'agent' || value.consumer === 'main-reader') &&
'paths' in value &&
Array.isArray(value.paths) &&
value.paths.every((path: unknown) => typeof path === 'string')
)
}
Loading
Loading