diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts index 91a7fd3881e..82f0d97e605 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts @@ -7,7 +7,9 @@ // an upload is recorded by the message that names it (`agent-session-attachment-claims.ts`). import { randomUUID } from 'node:crypto' -import { appendFile, mkdir, rm, writeFile } from 'node:fs/promises' +import { constants, createWriteStream } from 'node:fs' +import { appendFile, lstat, mkdir, open, rm, writeFile } from 'node:fs/promises' +import { pipeline } from 'node:stream/promises' import { extname, join } from 'node:path' import { sanitizeAgentSessionAttachmentName, @@ -30,6 +32,7 @@ import { const UPLOAD_MAX_CONCURRENT = 8 const UPLOAD_IDLE_TTL_MS = 5 * 60 * 1000 +const FRESH_FILE_STREAM_HIGH_WATER_MARK = 1024 * 1024 const NOT_FOUND = 'Attachment upload was not found' const NOT_AN_IMAGE = 'Not an attachment image' const PREVIEW_TOO_LARGE = 'file_too_large' @@ -173,6 +176,64 @@ export class AgentSessionAttachmentStore { return finalPath } + /** Trusted local intake of a recorded unpublished 0600 snapshot, never a user path. */ + async importFreshFile(name: string, sourcePath: string, byteLength: number): Promise { + const uploadDir = join(this.rootDir, randomUUID()) + const finalPath = join(uploadDir, sanitizeAgentSessionAttachmentName(name)) + try { + await mkdir(uploadDir, { recursive: true }) + try { + // Rename is the commit: nothing fallible may follow a successful move. + await renameFileWithWindowsRetryAsync(sourcePath, finalPath) + return finalPath + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'EXDEV')) { + throw error + } + } + const partPath = join(uploadDir, AGENT_SESSION_ATTACHMENT_PART_FILE) + const handle = await open(sourcePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + try { + const before = await handle.stat() + if (!before.isFile() || before.size !== byteLength) { + throw new Error('Snapshot changed') + } + await (byteLength === 0 + ? writeFile(partPath, '', { flag: 'wx', mode: 0o600 }) + : pipeline( + handle.createReadStream({ + start: 0, + end: byteLength - 1, + autoClose: false, + highWaterMark: FRESH_FILE_STREAM_HIGH_WATER_MARK + }), + createWriteStream(partPath, { + flags: 'wx', + mode: 0o600, + highWaterMark: FRESH_FILE_STREAM_HIGH_WATER_MARK + }) + )) + const after = await handle.stat() + if ( + (await lstat(partPath)).size !== byteLength || + after.size !== before.size || + after.mtimeMs !== before.mtimeMs || + after.ino !== before.ino || + after.dev !== before.dev + ) { + throw new Error('Snapshot changed') + } + } finally { + await handle.close() + } + await renameFileWithWindowsRetryAsync(partPath, finalPath) + return finalPath + } catch (error) { + await removeQuietly(uploadDir) + throw error + } + } + async abortUpload(args: { callerKey: string; uploadId: string }): Promise<{ aborted: true }> { const upload = this.uploads.peek(args.uploadId) if (upload) { diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 1a10bee3391..4992a5b6d4d 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -38,6 +38,8 @@ import { import { startFolderRepoGitUpgradeWatch } from '../ipc/folder-repo-git-upgrade' import { scheduleMainWindowAutoUpdaterSetup } from './main-window-updater' import { registerRuntimeWindowLifecycle } from './runtime-window-lifecycle' +import { ensureStructuredAgentSessionHostUnlessRefused } from '../runtime/structured-agent-session-host-refusal' +import { getAgentSessionAttachmentStore } from '../native-chat/agent-session-attachments/agent-session-attachment-store-registry' import { registerDroppedPathPreparation } from './dropped-path-preparation-ipc' export { ensureAutoUpdaterConfigured, registerUpdaterHandlers } from './main-window-updater' @@ -124,7 +126,12 @@ export function attachMainWindowServices( void hydrateLocalPtyRegistryAtBoot(store) } registerSshHandlers(store, () => mainWindow, runtime) - registerDroppedPathPreparation(mainWindow) + registerDroppedPathPreparation(mainWindow, async () => { + await ensureStructuredAgentSessionHostUnlessRefused(() => + runtime.ensureStructuredAgentSessionHost() + ) + return getAgentSessionAttachmentStore() + }) registerTccPromptNoticeHandlers(mainWindow) scheduleMainWindowAutoUpdaterSetup(mainWindow, store, options) registerRuntimeWindowLifecycle(mainWindow, runtime) diff --git a/src/main/window/dragged-temp-file-copy.test.ts b/src/main/window/dragged-temp-file-copy.test.ts index c93732b6336..606cb927dbc 100644 --- a/src/main/window/dragged-temp-file-copy.test.ts +++ b/src/main/window/dragged-temp-file-copy.test.ts @@ -2,7 +2,6 @@ import type * as NodeFs from 'node:fs' import { appendFileSync } from 'node:fs' import { chmod, - lstat, mkdir, mkdtemp, readdir, @@ -10,7 +9,6 @@ import { rm, stat, symlink, - utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -47,11 +45,8 @@ vi.mock('../ipc/runtime-import-limits', async (importOriginal) => ({ })) import { - DRAG_TEMP_COPY_TTL_MS, materializeDragTempPaths, mayNeedDragTempCopy, - scheduleDragTempCopySweep, - sweepExpiredDragTempCopies, type DragTempCopyEnvironment } from './dragged-temp-file-copy' @@ -292,19 +287,15 @@ describe('materializeDragTempPaths', () => { expect(await copyDirs()).toHaveLength(2) }) - it('counts copies retained from earlier drops against the budget', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => undefined) + it('never refuses a drop for copies earlier drops left behind', async () => { const first = await dragTempFile('a.png', '0123456789') const second = await dragTempFile('b.png', '0123456789') - const third = await dragTempFile('c.png', '012345') const [firstDrop] = await materializeDragTempPaths([first], env) const [secondDrop] = await materializeDragTempPaths([second], env) - const [thirdDrop] = await materializeDragTempPaths([third], env) expect(importedPath(firstDrop)).not.toBe(first) - expect(secondDrop).toMatchObject({ status: 'uncopied', reason: 'storage-full' }) - expect(importedPath(thirdDrop)).not.toBe(third) + expect(importedPath(secondDrop)).not.toBe(second) expect(await copyDirs()).toHaveLength(2) }) @@ -425,52 +416,3 @@ describe('mayNeedDragTempCopy', () => { expect(mayNeedDragTempCopy(join('/', 'Users', 'me', 'Desktop', 'a.png'), 'darwin')).toBe(false) }) }) - -describe('sweepExpiredDragTempCopies', () => { - it('removes only expired orca-drop directories', async () => { - const source = await dragTempFile('shot.png', 'x') - const [oldCopy] = await materializeDragTempPaths([source], env) - const [freshCopy] = await materializeDragTempPaths([source], env) - const oldDir = dirname(importedPath(oldCopy)) - const freshDir = dirname(importedPath(freshCopy)) - const foreign = join(env.copyRoot, 'not-ours') - await mkdir(foreign) - const nowMs = Date.now() - const expired = new Date(nowMs - DRAG_TEMP_COPY_TTL_MS - 1000) - await utimes(oldDir, expired, expired) - await utimes(foreign, expired, expired) - - await sweepExpiredDragTempCopies(env.copyRoot, nowMs) - - await expect(lstat(oldDir)).rejects.toMatchObject({ code: 'ENOENT' }) - expect((await lstat(freshDir)).isDirectory()).toBe(true) - expect((await lstat(foreign)).isDirectory()).toBe(true) - }) - - it('does nothing when no copy root exists', async () => { - await expect(sweepExpiredDragTempCopies(join(root, 'nope'))).resolves.toBeUndefined() - }) -}) - -describe('scheduleDragTempCopySweep', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it('sweeps on macOS shortly after startup and then hourly, scheduling only once', async () => { - vi.useFakeTimers() - const getCopyRoot = vi.fn(() => join(root, 'nope')) - - scheduleDragTempCopySweep(getCopyRoot, 'linux') - expect(vi.getTimerCount()).toBe(0) - - scheduleDragTempCopySweep(getCopyRoot, 'darwin') - scheduleDragTempCopySweep(getCopyRoot, 'darwin') - expect(vi.getTimerCount()).toBe(2) - - await vi.advanceTimersByTimeAsync(30_000) - expect(getCopyRoot).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(60 * 60 * 1000) - expect(getCopyRoot).toHaveBeenCalledTimes(2) - }) -}) diff --git a/src/main/window/dragged-temp-file-copy.ts b/src/main/window/dragged-temp-file-copy.ts index cacd4f4c9c5..336035f610c 100644 --- a/src/main/window/dragged-temp-file-copy.ts +++ b/src/main/window/dragged-temp-file-copy.ts @@ -1,19 +1,16 @@ -import { constants, createWriteStream, type Dir, type Stats } from 'node:fs' -import { lstat, mkdtemp, open, opendir, readdir, realpath, rm, writeFile } from 'node:fs/promises' -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { constants, createWriteStream, type Stats } from 'node:fs' +import { lstat, mkdtemp, open, realpath, rm, writeFile } from 'node:fs/promises' +import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path' import { pipeline } from 'node:stream/promises' +import { AGENT_SESSION_ATTACHMENT_MAX_BYTES } from '../../shared/agent-session-attachments' +import { readNodeFileHandleWithinLimit } from '../../shared/node-bounded-file-reader' import type { NativeFileDropCopyFailureReason } from '../../shared/native-file-drop' import { formatByteCeiling, REMOTE_IMPORT_MAX_FILE_BYTES, REMOTE_IMPORT_MAX_TOTAL_BYTES } from '../ipc/runtime-import-limits' -import { - ensureOwnedTempStagingRoot, - getOwnedTempStagingRoot, - isSafeOwnedDirectory, - sweepExpiredOwnedDirectories -} from './owned-temp-staging-root' +import { ensureOwnedTempStagingRoot, getOwnedTempStagingRoot } from './owned-temp-staging-root' // Why: macOS screenshot thumbnails live in `$TMPDIR/TemporaryItems/NSIRD_*`, // which only processes attributed to Orca main may open. The detached PTY @@ -21,14 +18,10 @@ import { const TEMPORARY_ITEMS_SEGMENT = 'TemporaryItems' const DRAG_PROVIDER_DIR_PREFIX = 'NSIRD_' -const COPY_ROOT_NAME = 'orca-drops' const COPY_DIR_PREFIX = 'orca-drop-' -const COPY_DIR_PATTERN = /^orca-drop-[A-Za-z0-9]{6}$/ -// Why: open drafts and startup prompts read the copy lazily, often days later, so -// keep it well past the drop; the TTL still bounds what the copy budget holds. -export const DRAG_TEMP_COPY_TTL_MS = 7 * 24 * 60 * 60 * 1000 -const SWEEP_FIRST_DELAY_MS = 30 * 1000 -const SWEEP_INTERVAL_MS = 60 * 60 * 1000 +const DRAG_TEMP_COPY_ROOT_NAME = 'orca-drops' +// Why no expiry: a copy may be what a terminal agent, a draft or a sent message still names, and +// nothing tells which; fresh structured-chat copies enter the chat store before publication. export type DragTempCopyEnvironment = { platform: NodeJS.Platform @@ -39,13 +32,26 @@ export type DragTempCopyEnvironment = { } export type DragTempCopyItemResult = - | { sourcePath: string; status: 'imported'; destPath: string } + | { + sourcePath: string + status: 'imported' + destPath: string + byteLength?: number + directory?: string + } + | { sourcePath: string; status: 'buffered'; bytes: Buffer } /** Left as the original path, which only main's children can open. */ | { sourcePath: string; status: 'uncopied'; reason: 'too-large' | 'storage-full' } | { sourcePath: string; status: 'failed'; reason: NativeFileDropCopyFailureReason } +export class DragTempCopyBatch { + readonly completed = new Map() + readonly directories = new Set() + constructor(public remainingMemory = -1) {} +} + export function getDragTempCopyRoot(appTempRoot: string): string { - return getOwnedTempStagingRoot(appTempRoot, COPY_ROOT_NAME) + return getOwnedTempStagingRoot(appTempRoot, DRAG_TEMP_COPY_ROOT_NAME) } /** Lexical check only: whether a path could be a drag-temp file worth inspecting. */ @@ -55,53 +61,81 @@ export function mayNeedDragTempCopy(path: string, platform: NodeJS.Platform): bo /** * Copy every drag-temp path in a drop into Orca-owned storage, sequentially, - * under the byte budget storage has left. Other paths pass through unchanged. + * within the per-drop byte budget. Other paths pass through unchanged. */ export async function materializeDragTempPaths( paths: readonly string[], env: DragTempCopyEnvironment, - signal?: AbortSignal + signal?: AbortSignal, + batch = new DragTempCopyBatch() ): Promise { - const results: DragTempCopyItemResult[] = [] - const completed = new Map() - // Why: the budget spans every retained copy, so repeated drops cannot fill the disk. - let remainingBytes = - env.platform === 'darwin' - ? REMOTE_IMPORT_MAX_TOTAL_BYTES - (await measureRetainedCopyBytes(env.copyRoot)) - : 0 + const { completed } = batch + // Why per drop: copies are never swept, so earlier drops must not refuse later ones. + let remainingBytes = env.platform === 'darwin' ? REMOTE_IMPORT_MAX_TOTAL_BYTES : 0 try { for (const sourcePath of paths) { signal?.throwIfAborted() - // Why: reuse one copy so composer de-duplication still sees equal paths. - const previous = completed.get(sourcePath) - if (previous) { - results.push(previous) + if (completed.has(sourcePath)) { continue } - const { result, copiedBytes } = await materializeDragTempPath( - sourcePath, - remainingBytes, - env, - signal - ) - remainingBytes -= copiedBytes - completed.set(sourcePath, result) - results.push(result) + let capture = await materializeDragTempPath(sourcePath, remainingBytes, env, signal, batch) + while (!capture) { + batch.remainingMemory = -1 + for (const [path, result] of completed) { + if (result.status === 'buffered') { + const staged = await stageCapturedDragTempBytes(path, result.bytes, env, batch, signal) + completed.set(path, staged) + if (staged.status === 'failed') { + remainingBytes += result.bytes.length + } + } + } + // Spill waits invalidate prospective provider stats: acquire it normally again. + capture = await materializeDragTempPath(sourcePath, remainingBytes, env, signal, batch) + } + remainingBytes -= capture.copiedBytes + completed.set(sourcePath, capture.result) } signal?.throwIfAborted() - return results + return paths.map((path) => completed.get(path)!) } catch (error) { - // Why: a caller that gave up never hands these out; don't hold budget until the sweep. - await removeCopies(completed.values()) + completed.clear() + await removeCreatedDragTempCopies(batch.directories) throw error } } -async function removeCopies(results: Iterable): Promise { - for (const result of results) { - if (result.status === 'imported' && result.destPath !== result.sourcePath) { - await rm(dirname(result.destPath), { recursive: true, force: true }).catch(() => undefined) +export async function removeCreatedDragTempCopies(directories: Iterable): Promise { + for (const directory of directories) { + await rm(directory, { recursive: true, force: true }).catch(() => undefined) + } +} + +export async function stageCapturedDragTempBytes( + sourcePath: string, + bytes: Buffer, + env: DragTempCopyEnvironment, + batch: DragTempCopyBatch, + signal?: AbortSignal +): Promise { + let directory: string | undefined + try { + signal?.throwIfAborted() + directory = await createCopyDirectory(env.copyRoot, batch) + signal?.throwIfAborted() + const destPath = join(directory, basename(sourcePath)) + await writeFile(destPath, bytes, { flag: 'wx', mode: 0o600, signal }) + if ((await lstat(destPath)).size !== bytes.length) { + throw new DropCopyError('changed') } + signal?.throwIfAborted() + return { sourcePath, status: 'imported', destPath, byteLength: bytes.length, directory } + } catch (error) { + if (directory) { + await removeCreatedDragTempCopies([directory]) + } + signal?.throwIfAborted() + return classifyFailure(sourcePath, error) } } @@ -109,8 +143,9 @@ export async function materializeDragTempPath( sourcePath: string, remainingBytes: number, env: DragTempCopyEnvironment, - signal?: AbortSignal -): Promise<{ result: DragTempCopyItemResult; copiedBytes: number }> { + signal?: AbortSignal, + batch = new DragTempCopyBatch() +): Promise<{ result: DragTempCopyItemResult; copiedBytes: number } | null> { const passThrough = { result: imported(sourcePath, sourcePath), copiedBytes: 0 } if (!mayNeedDragTempCopy(sourcePath, env.platform)) { return passThrough @@ -158,7 +193,28 @@ export async function materializeDragTempPath( } signal?.throwIfAborted() - copyDir = await createCopyDirectory(env.copyRoot) + if (batch.remainingMemory >= 0) { + if (size > batch.remainingMemory) { + return null + } + const read = await readNodeFileHandleWithinLimit( + handle, + AGENT_SESSION_ATTACHMENT_MAX_BYTES, + { + regularFileOnly: true, + signal, + window: { offset: 0, length: size } + } + ) + if (!isSameSnapshot(read.stats, opened) || !isSameSnapshot(await handle.stat(), opened)) { + throw new DropCopyError('changed') + } + signal?.throwIfAborted() + batch.remainingMemory -= size + return { result: { sourcePath, status: 'buffered', bytes: read.buffer }, copiedBytes: size } + } + copyDir = await createCopyDirectory(env.copyRoot, batch) + signal?.throwIfAborted() const destPath = join(copyDir, basename(canonicalSource)) // Why: stream from the checked handle, capped at the inspected size, so a // swapped or growing source cannot slip through; unlike cp, ditto or @@ -179,7 +235,10 @@ export async function materializeDragTempPath( // Why: a caller that gave up mid-copy never hands this path out; don't retain it. signal?.throwIfAborted() console.debug('[drop] copied a drag-temp file into Orca storage', { bytes: size }) - return { result: imported(sourcePath, destPath), copiedBytes: size } + return { + result: { sourcePath, status: 'imported', destPath, byteLength: size, directory: copyDir }, + copiedBytes: size + } } finally { await handle.close() } @@ -195,75 +254,6 @@ export async function materializeDragTempPath( } } -/** Remove `orca-drop-*` copies older than the TTL; younger ones may still be read lazily. */ -export async function sweepExpiredDragTempCopies( - copyRoot: string, - nowMs = Date.now() -): Promise { - try { - if (!isSafeOwnedDirectory(await lstat(copyRoot))) { - return - } - } catch { - // Missing or unreadable root: nothing of ours to sweep. - return - } - await sweepExpiredOwnedDirectories(copyRoot, { - nowMs, - ttlMs: DRAG_TEMP_COPY_TTL_MS, - ownsEntry: (name) => COPY_DIR_PATTERN.test(name) - }) -} - -/** Bytes held by `orca-drop-*` copies still on disk; unreadable entries count as zero. */ -async function measureRetainedCopyBytes(copyRoot: string): Promise { - let total = 0 - let rootDir: Dir - try { - rootDir = await opendir(copyRoot) - } catch { - return 0 - } - try { - for await (const entry of rootDir) { - if (!entry.isDirectory() || !COPY_DIR_PATTERN.test(entry.name)) { - continue - } - const copyDir = join(copyRoot, entry.name) - const names = await readdir(copyDir).catch(() => []) - for (const name of names) { - total += await lstat(join(copyDir, name)).then( - (stats) => (stats.isFile() ? stats.size : 0), - () => 0 - ) - } - } - } catch { - // A partial count still bounds growth; the next drop measures again. - } - return total -} - -let sweepScheduled = false - -/** Sweep shortly after startup, then hourly, so a long-running app still expires copies. */ -export function scheduleDragTempCopySweep( - getCopyRoot: () => string, - platform: NodeJS.Platform = process.platform -): void { - if (sweepScheduled || platform !== 'darwin') { - return - } - sweepScheduled = true - const sweep = (): void => { - void Promise.resolve() - .then(() => sweepExpiredDragTempCopies(getCopyRoot())) - .catch(() => undefined) - } - setTimeout(sweep, SWEEP_FIRST_DELAY_MS).unref() - setInterval(sweep, SWEEP_INTERVAL_MS).unref() -} - // True when the segments hold `TemporaryItems/NSIRD_*/`: something below the provider dir. function hasDragTempMarker(segments: readonly string[]): boolean { for (let i = 0; i + 2 < segments.length; i += 1) { @@ -293,12 +283,14 @@ function isPathWithin(root: string, candidate: string): boolean { return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)) } -async function createCopyDirectory(copyRoot: string): Promise { +async function createCopyDirectory(copyRoot: string, batch: DragTempCopyBatch): Promise { try { if (!(await ensureOwnedTempStagingRoot(copyRoot))) { throw new DropCopyError('storage-not-private') } - return await mkdtemp(join(copyRoot, COPY_DIR_PREFIX)) + const directory = await mkdtemp(join(copyRoot, COPY_DIR_PREFIX)) + batch.directories.add(directory) + return directory } catch (error) { if (error instanceof DropCopyError) { throw error diff --git a/src/main/window/dropped-path-preparation-ipc.test.ts b/src/main/window/dropped-path-preparation-ipc.test.ts index 74b8a29a00a..87a9192eea0 100644 --- a/src/main/window/dropped-path-preparation-ipc.test.ts +++ b/src/main/window/dropped-path-preparation-ipc.test.ts @@ -7,9 +7,8 @@ import type { DragTempCopyItemResult } from './dragged-temp-file-copy' type IpcListener = (event: { sender: unknown }, payload: unknown) => void type InvokeHandler = (event: { sender: unknown }, payload: unknown) => Promise -const { materializeMock, sweepMock, ipcListeners, ipcHandlers } = vi.hoisted(() => ({ +const { materializeMock, ipcListeners, ipcHandlers } = vi.hoisted(() => ({ materializeMock: vi.fn(), - sweepMock: vi.fn(), ipcListeners: new Map(), ipcHandlers: new Map() })) @@ -31,8 +30,7 @@ vi.mock('electron', () => ({ vi.mock('./dragged-temp-file-copy', async (importOriginal) => ({ ...(await importOriginal()), - materializeDragTempPaths: materializeMock, - scheduleDragTempCopySweep: sweepMock + materializeDragTempPaths: materializeMock })) vi.mock('./darwin-user-temp-dir', () => ({ @@ -99,15 +97,13 @@ describe('registerDroppedPathPreparation', () => { beforeEach(() => { ipcListeners.clear() ipcHandlers.clear() - sweepMock.mockReset() }) - it('registers only the preparation request and starts copy expiry', () => { + it('registers only the preparation request', () => { 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 () => { @@ -123,7 +119,9 @@ describe('registerDroppedPathPreparation', () => { ]) { expect(() => handler({ sender: webContents }, invalid)).toThrow('Invalid') } - await expect(handler({ sender: webContents }, request)).resolves.toEqual({ + await expect( + handler({ sender: webContents }, { ...request, consumer: 'local-chat' }) + ).resolves.toEqual({ paths: [FINDER], failures: [] }) diff --git a/src/main/window/dropped-path-preparation-ipc.ts b/src/main/window/dropped-path-preparation-ipc.ts index df87886c0a7..fd445b7ae97 100644 --- a/src/main/window/dropped-path-preparation-ipc.ts +++ b/src/main/window/dropped-path-preparation-ipc.ts @@ -8,14 +8,18 @@ import type { PreparedDroppedPaths } from '../../shared/native-file-drop-preparation' import { abortWhenRendererGone } from '../ipc/renderer-lifetime-abort' -import { getDragTempCopyRoot, scheduleDragTempCopySweep } from './dragged-temp-file-copy' +import { getDragTempCopyRoot } from './dragged-temp-file-copy' import { createDroppedPathPreparationQueue, - windowDragTempCopyLane + windowDragTempCopyLane, + type DroppedPathPreparationDeps } from './dropped-path-preparation' import { getDarwinUserTempDir } from './darwin-user-temp-dir' -export function registerDroppedPathPreparation(mainWindow: BrowserWindow): void { +export function registerDroppedPathPreparation( + mainWindow: BrowserWindow, + localChatAttachmentStore?: DroppedPathPreparationDeps['localChatAttachmentStore'] +): void { const prepareChannel = 'fs:prepareDroppedPaths' const mainWebContents = mainWindow.webContents const isWindowGone = (): boolean => mainWindow.isDestroyed() || mainWebContents.isDestroyed() @@ -23,6 +27,7 @@ export function registerDroppedPathPreparation(mainWindow: BrowserWindow): void const prepare = createDroppedPathPreparationQueue( { platform: process.platform, + localChatAttachmentStore, getCopyEnvironment: async () => ({ platform: process.platform, sourceTempRoot: await getDarwinUserTempDir(), @@ -60,7 +65,6 @@ export function registerDroppedPathPreparation(mainWindow: BrowserWindow): void activePrepareHandler = undefined } }) - scheduleDragTempCopySweep(() => getDragTempCopyRoot(app.getPath('temp'))) } let activePrepareHandler: unknown @@ -70,7 +74,9 @@ function isPrepareDroppedPathsRequest(value: unknown): value is PrepareDroppedPa !!value && typeof value === 'object' && 'consumer' in value && - (value.consumer === 'agent' || value.consumer === 'main-reader') && + (value.consumer === 'agent' || + value.consumer === 'main-reader' || + value.consumer === 'local-chat') && 'paths' in value && Array.isArray(value.paths) && value.paths.every((path: unknown) => typeof path === 'string') diff --git a/src/main/window/dropped-path-preparation.test.ts b/src/main/window/dropped-path-preparation.test.ts index 54d288d32fa..b4a30b6fe7a 100644 --- a/src/main/window/dropped-path-preparation.test.ts +++ b/src/main/window/dropped-path-preparation.test.ts @@ -1,5 +1,23 @@ -import { join } from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromises from 'node:fs/promises' +import { + mkdtemp, + mkdir, + open, + readFile, + readdir, + realpath, + rename, + rm, + stat, + utimes, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { AgentSessionAttachmentStore } from '../native-chat/agent-session-attachments/agent-session-attachment-store' +import { AGENT_SESSION_ATTACHMENT_MAX_BYTES } from '../../shared/agent-session-attachments' +import { runProcess } from '@orca/process-host' +import { basename, dirname, join } from 'node:path' +import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import type { DragTempCopyItemResult } from './dragged-temp-file-copy' import type * as DragTempFileCopy from './dragged-temp-file-copy' import { @@ -9,6 +27,10 @@ import { } from './dropped-path-preparation' const materialize = vi.hoisted(() => vi.fn()) +vi.mock('node:fs/promises', async (original) => { + const actual = await original() + return { ...actual, open: vi.fn(actual.open), rename: vi.fn(actual.rename) } +}) vi.mock('electron', () => ({ app: {}, ipcMain: {} })) vi.mock('./dragged-temp-file-copy', async (original) => ({ ...(await original()), @@ -37,9 +59,9 @@ function queue(lane = createDragTempCopyLane(), copyTimeoutMs = 1000) { dispose } } -function deferred() { - let resolve!: (value: DragTempCopyItemResult[]) => void - const promise = new Promise((settle) => { +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { resolve = settle }) return { promise, resolve } @@ -49,9 +71,198 @@ async function settle() { } beforeEach(() => { materialize.mockReset() + vi.mocked(open).mockClear() + vi.mocked(rename).mockClear() }) +async function realDrop() { + const root = await mkdtemp(join(tmpdir(), 'orca-30419-')) + onTestFinished(() => rm(root, { recursive: true, force: true })) + const sourceTempRoot = join(root, 'T') + const source = join(sourceTempRoot, 'TemporaryItems', 'NSIRD_test', 'shot.png') + await mkdir(join(source, '..'), { recursive: true }) + await writeFile(source, 'image') + const actual = await vi.importActual('./dragged-temp-file-copy') + materialize.mockImplementation(actual.materializeDragTempPaths) + return { + source, + env: { platform: 'darwin' as const, sourceTempRoot, copyRoot: join(root, 'drops') }, + store: new AgentSessionAttachmentStore(join(root, 'store'), { hasSession: () => false }) + } +} + +async function xattr(args: string[]) { + return runProcess({ program: '/usr/bin/xattr', args }) +} + +function localQueue( + environment: DragTempFileCopy.DragTempCopyEnvironment, + getStore: () => Promise, + copyTimeoutMs = 3000 +) { + return createDroppedPathPreparationQueue({ + platform: 'darwin', + getCopyEnvironment: async () => environment, + watchRenderer: () => ({ signal: new AbortController().signal, dispose: vi.fn() }), + localChatAttachmentStore: getStore, + copyTimeoutMs + }) +} + describe('dropped path preparation through the shared copy queue', () => { + it.each([false, true])('stores snapshots, preserves reuse: spill=%s', async (spill) => { + const { source, env, store } = await realDrop() + const enqueue = localQueue(env, async () => store) + const reused = (await enqueue({ paths: [source], consumer: 'agent' })).paths[0] + const sibling = join(dirname(reused), 'notes.txt') + await writeFile(sibling, 'keep') + const second = join(dirname(source), 'large.png') + const bytes = Buffer.alloc(spill ? AGENT_SESSION_ATTACHMENT_MAX_BYTES : 0, 7) + await writeFile(second, bytes) + const write = vi.spyOn(store, 'storeFile') + const move = vi.spyOn(store, 'importFreshFile') + vi.mocked(open).mockClear() + const result = await enqueue({ + paths: [source, source, reused, second], + consumer: 'local-chat' + }) + expect(result.paths[0]).toBe(result.paths[1]) + expect(result.paths[2]).toBe(reused) + expect(await readFile(result.paths[0], 'utf8')).toBe('image') + expect(await readFile(reused, 'utf8')).toBe('image') + expect(await readFile(sibling, 'utf8')).toBe('keep') + expect(write).toHaveBeenCalledTimes(spill ? 0 : 2) + expect(move).toHaveBeenCalledTimes(spill ? 2 : 0) + const provider = await realpath(env.sourceTempRoot) + expect(vi.mocked(open).mock.calls.every(([path]) => String(path).startsWith(provider))).toBe( + true + ) + expect((await readFile(result.paths[3])).equals(bytes)).toBe(true) + expect(await readdir(env.copyRoot)).toHaveLength(1) + }) + + it.each([false, true])('keeps immutable fallback: spill=%s', async (spill) => { + const { source, env } = await realDrop() + const second = join(dirname(source), 'second.png') + const bytes = spill + ? Buffer.alloc(AGENT_SESSION_ATTACHMENT_MAX_BYTES, 7) + : Buffer.from('second') + await writeFile(second, bytes) + const started = deferred() + const release = deferred() + const enqueue = localQueue(env, async () => { + started.resolve() + await release.promise + throw new Error('store unavailable') + }) + const pending = enqueue({ paths: [source, second, source], consumer: 'local-chat' }) + await started.promise + const staged = await readdir(env.copyRoot).catch(() => []) + expect(staged).toHaveLength(spill ? 2 : 0) + await rm(source) + await writeFile(second, Buffer.alloc(bytes.length, 9)) + release.resolve(null) + const result = await pending + expect(result.failures).toEqual([]) + expect(result.paths[0]).toBe(result.paths[2]) + expect(await readFile(result.paths[0], 'utf8')).toBe('image') + expect((await readFile(result.paths[1])).equals(bytes)).toBe(true) + }) + + it.each(['rename', 'EXDEV', 'failure'] as const)('commits fresh staging: %s', async (mode) => { + const { source, env, store } = await realDrop() + await utimes(source, new Date(0), new Date(0)) + if (process.platform === 'darwin') { + expect((await xattr(['-w', 'com.orca.test-marker', '1', source])).code).toBe(0) + } + const enqueue = localQueue(env, async () => store) + const staging = (await enqueue({ paths: [source], consumer: 'agent' })).paths[0] + const before = await stat(staging) + const realRename = (await vi.importActual('node:fs/promises')).rename + vi.mocked(rename).mockImplementationOnce(async (from, to) => { + expect(await readFile(staging, 'utf8')).toBe('image') + if (mode !== 'rename') { + throw Object.assign(new Error(mode), { code: mode === 'EXDEV' ? mode : 'EIO' }) + } + return realRename(from, to) + }) + const pending = store.importFreshFile('../shot.png', staging, 5) + if (mode === 'failure') { + await expect(pending).rejects.toThrow('failure') + expect(await readFile(staging, 'utf8')).toBe('image') + expect(await readdir(store.rootDir)).toEqual([]) + } else { + const stored = await pending + expect(basename(stored)).toBe('shot.png') + expect(basename(dirname(stored))).toMatch(/^[0-9a-f-]{36}$/) + expect(await readFile(stored, 'utf8')).toBe('image') + expect((await stat(dirname(stored))).mtimeMs).toBeGreaterThan(Date.now() - 60_000) + expect((await stat(stored)).mtimeMs).toBeGreaterThanOrEqual(before.mtimeMs) + if (process.platform !== 'win32') { + expect((await stat(stored)).mode & 0o777).toBe(0o600) + } + if (mode === 'rename') { + await expect(stat(staging)).rejects.toMatchObject({ code: 'ENOENT' }) + } else { + expect(await readFile(staging, 'utf8')).toBe('image') + } + if (process.platform === 'darwin') { + expect((await xattr([stored])).stdout).not.toContain('com.orca.test-marker') + } + } + }) + + it.each([false, true])('advances after abandon: spill=%s', async (spill) => { + const { source, env, store } = await realDrop() + const paths = [source] + if (spill) { + paths.push(join(dirname(source), 'large.png')) + await writeFile(paths[1], Buffer.alloc(AGENT_SESSION_ATTACHMENT_MAX_BYTES)) + } + const started = deferred() + const completed = deferred() + const release = deferred() + onTestFinished(() => release.resolve('done')) + const hold = async (publish: () => Promise) => { + let final = spill ? await publish() : undefined + started.resolve() + await release.promise + final ??= await publish() + completed.resolve(final) + return final + } + if (spill) { + vi.spyOn(store, 'importFreshFile').mockImplementationOnce((...args) => + hold(() => AgentSessionAttachmentStore.prototype.importFreshFile.apply(store, args)) + ) + } else { + vi.spyOn(store, 'storeFile').mockImplementationOnce((...args) => + hold(() => AgentSessionAttachmentStore.prototype.storeFile.apply(store, args)) + ) + } + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + onTestFinished(() => void vi.useRealTimers()) + const enqueue = localQueue(env, async () => store, 100) + const abandoned = enqueue({ paths, consumer: 'local-chat' }) + await started.promise + const following = enqueue({ paths: [source], consumer: 'agent' }) + await vi.advanceTimersByTimeAsync(100) + await expect(abandoned).resolves.toMatchObject({ + paths: [], + failures: [{ commonReason: 'timed-out' }] + }) + expect(await readFile((await following).paths[0], 'utf8')).toBe('image') + vi.useRealTimers() + await vi.waitFor(async () => expect(await readdir(env.copyRoot)).toHaveLength(1)) + release.resolve('done') + const final = await completed.promise + expect(await readFile(final, 'utf8')).toBe('image') + expect(store.isUploadInFlight(basename(dirname(final)))).toBe(false) + expect(await readFile(source, 'utf8')).toBe('image') + expect(await readdir(env.copyRoot)).toHaveLength(1) + await expect(abandoned).resolves.toMatchObject({ paths: [] }) + }) + it.each(['agent', 'main-reader'] as const)( 'passes ordinary paths without copying for %s', async (consumer) => { diff --git a/src/main/window/dropped-path-preparation.ts b/src/main/window/dropped-path-preparation.ts index 1012989a871..265488fc438 100644 --- a/src/main/window/dropped-path-preparation.ts +++ b/src/main/window/dropped-path-preparation.ts @@ -1,10 +1,17 @@ +import { basename } from 'node:path' +import { AGENT_SESSION_ATTACHMENT_MAX_BYTES } from '../../shared/agent-session-attachments' +import type { AgentSessionAttachmentStore } from '../native-chat/agent-session-attachments/agent-session-attachment-store' import type { NativeFileDropCopyFailureReason } from '../../shared/native-file-drop' import type { PrepareDroppedPathsRequest, PreparedDroppedPaths } from '../../shared/native-file-drop-preparation' import { + DragTempCopyBatch, + stageCapturedDragTempBytes, materializeDragTempPaths, + removeCreatedDragTempCopies, + type DragTempCopyItemResult, mayNeedDragTempCopy, type DragTempCopyEnvironment } from './dragged-temp-file-copy' @@ -17,6 +24,10 @@ export type DroppedPathPreparationDeps = { platform: NodeJS.Platform getCopyEnvironment: () => Promise watchRenderer: () => RendererDropLifetime + localChatAttachmentStore?: () => Promise | null> copyTimeoutMs?: number } @@ -24,7 +35,7 @@ export function createDragTempCopyLane(): { tail: Promise; pending: number return { tail: Promise.resolve(), pending: 0 } } -// Why: replacement windows still share the retained-copy budget with work already in flight. +// Why: replacement windows share the copy queue with work already in flight. export const windowDragTempCopyLane = createDragTempCopyLane() export function createDroppedPathPreparationQueue( @@ -61,19 +72,41 @@ export function createDroppedPathPreparationQueue( export async function prepareDroppedPaths( request: PrepareDroppedPathsRequest, env: DragTempCopyEnvironment, - signal?: AbortSignal + signal?: AbortSignal, + localChatAttachmentStore?: DroppedPathPreparationDeps['localChatAttachmentStore'] ): Promise { - const results = await materializeDragTempPaths(request.paths, env, signal) + const batch = new DragTempCopyBatch( + request.consumer === 'local-chat' ? AGENT_SESSION_ATTACHMENT_MAX_BYTES : -1 + ) + let prepared: DragTempCopyItemResult[] + try { + const capture = materializeDragTempPaths(request.paths, env, signal, batch) + prepared = await (signal ? rejectOnDropAbort(capture, signal) : capture) + if (request.consumer === 'local-chat') { + prepared = [] + const handoff = storeCreatedCopies(batch, env, localChatAttachmentStore, signal) + await (signal ? rejectOnDropAbort(handoff, signal) : handoff) + prepared = request.paths.map((path) => batch.completed.get(path)!) + } + signal?.throwIfAborted() + batch.completed.clear() + } catch (error) { + batch.completed.clear() + await removeCreatedDragTempCopies(batch.directories) + throw error + } const acceptsOriginal = request.consumer === 'main-reader' - const paths = results.flatMap((result) => + const paths = prepared.flatMap((result) => result.status === 'imported' ? [result.destPath] : result.status === 'uncopied' && acceptsOriginal ? [result.sourcePath] : [] ) - const unprepared = results.flatMap((result) => - result.status === 'imported' || (result.status === 'uncopied' && acceptsOriginal) + const unprepared = prepared.flatMap((result) => + result.status === 'buffered' || + result.status === 'imported' || + (result.status === 'uncopied' && acceptsOriginal) ? [] : [result] ) @@ -86,6 +119,55 @@ export async function prepareDroppedPaths( } } +async function storeCreatedCopies( + batch: DragTempCopyBatch, + env: DragTempCopyEnvironment, + getStore: DroppedPathPreparationDeps['localChatAttachmentStore'], + signal?: AbortSignal +): Promise { + let storePromise: + | ReturnType> + | undefined + for (const [sourcePath, result] of batch.completed) { + signal?.throwIfAborted() + const buffered = result.status === 'buffered' + const file = + result.status === 'imported' && + result.directory && + result.byteLength !== undefined && + result.byteLength <= AGENT_SESSION_ATTACHMENT_MAX_BYTES + if (!buffered && !file) { + continue + } + let stored: string | undefined + try { + const store = await (storePromise ??= getStore?.().catch(() => null) ?? Promise.resolve(null)) + signal?.throwIfAborted() + if (store) { + stored = buffered + ? await store.storeFile(basename(sourcePath), result.bytes) + : result.status === 'imported' && result.byteLength !== undefined + ? await store.importFreshFile(basename(sourcePath), result.destPath, result.byteLength) + : undefined + } + } catch { + signal?.throwIfAborted() + } + signal?.throwIfAborted() + if (stored) { + batch.completed.set(sourcePath, { sourcePath, status: 'imported', destPath: stored }) + if (result.status === 'imported' && result.directory) { + await removeCreatedDragTempCopies([result.directory]) + } + } else if (buffered) { + batch.completed.set( + sourcePath, + await stageCapturedDragTempBytes(sourcePath, result.bytes, env, batch, signal) + ) + } + } +} + function failDroppedPathPreparation( paths: readonly string[], platform: NodeJS.Platform, @@ -112,7 +194,9 @@ async function copyDroppedPaths( const signal = AbortSignal.any([rendererSignal, timeout.signal]) try { return await rejectOnDropAbort( - deps.getCopyEnvironment().then((env) => prepareDroppedPaths(request, env, signal)), + deps + .getCopyEnvironment() + .then((env) => prepareDroppedPaths(request, env, signal, deps.localChatAttachmentStore)), signal ) } catch (error) { diff --git a/src/renderer/src/components/native-chat/NativeChatPaneFileDropSurface.tsx b/src/renderer/src/components/native-chat/NativeChatPaneFileDropSurface.tsx index 4c10a36e979..2bed42fef1c 100644 --- a/src/renderer/src/components/native-chat/NativeChatPaneFileDropSurface.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPaneFileDropSurface.tsx @@ -51,7 +51,7 @@ export function NativeChatPaneFileDropSurface({ const ownerRef = useRef(null) const [sequence] = useState(createOsFileDropSequence) const attachOwner = useOsFileDropOwner(ownerRef, { - consumer: 'agent', + consumer: (destination) => destination?.preparationConsumer ?? 'agent', sequence, canAccept: () => Boolean(registration && !registration.getClaim().disabled), captureDestination: () => registration?.getClaim().captureExternalDrop(), diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx index e1dbd940397..ee6bc8cfb2a 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx @@ -32,6 +32,8 @@ import { handleAppMenuPasteRequest } from '@/lib/app-menu-paste' import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge' import type { NativeChatComposerHandle } from './native-chat-composer-types' import { routeNativeChatRootKeyToInput } from './native-chat-root-key-routing' +import * as OsFileDropOwner from '@/hooks/use-os-file-drop-owner' +import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' const externalAttachments = ExternalAttachments.useNativeChatExternalAttachments let chips: Parameters[0]['pendingChips'] @@ -274,10 +276,12 @@ const write = writeSpy as StructuredAgentSessionWrite function EditedCard({ imagesCapable, - acceptsImages = true + acceptsImages = true, + runtimeEnvironmentId = null }: { imagesCapable: boolean acceptsImages?: boolean + runtimeEnvironmentId?: string | null }): React.JSX.Element | null { const messages = [card] const { editor, begin } = useStructuredAgentSessionQueuedEdit({ @@ -291,7 +295,7 @@ function EditedCard({ useEffect(() => void begin('card'), [begin]) const attachTo = { terminalTabId: 'tab', - transport: { sessionId: 's', worktreeId: 'wt', runtimeEnvironmentId: null, acceptsImages } + transport: { sessionId: 's', worktreeId: 'wt', runtimeEnvironmentId, acceptsImages } } return editor ? ( @@ -330,6 +334,26 @@ function PastePane({ } describe('queued card image edit', () => { + it.each([null, 'paired'])( + 'prepares for the captured queued owner %s', + async (runtimeEnvironmentId) => { + const owner = vi.spyOn(OsFileDropOwner, 'useOsFileDropOwner') + try { + replaceRuntimeEnvironmentRevisions([{ id: 'paired', createdAt: 1, pairingRevision: 7 }]) + render() + await waitFor(() => expect(screen.getByRole('textbox')).toHaveProperty('readOnly', false)) + const options = owner.mock.calls.at(-1)![1] + const destination = options.captureDestination?.(new DragEvent('drop')) + const consumer = + typeof options.consumer === 'function' ? options.consumer(destination) : options.consumer + expect(consumer).toBe(runtimeEnvironmentId ? 'agent' : 'local-chat') + } finally { + replaceRuntimeEnvironmentRevisions([]) + owner.mockRestore() + } + } + ) + it('pastes into the owning queued edit through native key and menu delivery', async () => { let field: HTMLElement const ui = { diff --git a/src/renderer/src/components/native-chat/native-chat-pane-file-drop.ts b/src/renderer/src/components/native-chat/native-chat-pane-file-drop.ts index ac87f8d3e43..f141b37de1d 100644 --- a/src/renderer/src/components/native-chat/native-chat-pane-file-drop.ts +++ b/src/renderer/src/components/native-chat/native-chat-pane-file-drop.ts @@ -1,3 +1,4 @@ +import type { DroppedPathConsumer } from '../../../../shared/native-file-drop-preparation' import { hasWorkspaceFileDragType } from '@/lib/workspace-file-drag' import { hasNativeFileDragTypes } from '../../../../shared/native-file-drop' import { OS_FILE_DROP_OWNER_ATTRIBUTE } from '../../../../shared/native-file-drop-preparation' @@ -15,10 +16,14 @@ export type NativeChatPaneDragEvent = { relatedTarget: EventTarget | null } +export type CapturedNativeChatDrop = ((paths: string[]) => Promise) & { + preparationConsumer?: DroppedPathConsumer +} + /** The composer's drop claim, published while a composer is mounted in the pane. */ export type NativeChatPaneDropClaim = { destinationKey: string - captureExternalDrop: () => (paths: string[]) => Promise + captureExternalDrop: () => CapturedNativeChatDrop /** A guarded composer refuses the drop, so the pane must not invite one. */ disabled: boolean onDragOverCapture: (event: React.DragEvent) => void diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx index a04881875e6..b5f06290a22 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -464,20 +464,33 @@ describe('useNativeChatExternalAttachments', () => { expect(mocks.stat).not.toHaveBeenCalled() }) - it('refuses a captured drop if its destination changed before preparation finished', async () => { + it.each([null, 'env-1'])('fences captured results for %s', async (runtimeEnvironmentId) => { const chips = trackingChips() + const attachResolvedPaths = vi.fn() + const setNotice = vi.fn() const probe = await renderProbe({ structuredWorktreeId: 'worktree-1', - structuredSession: session, - attachResolvedPaths: vi.fn(), + structuredSession: { ...session, runtimeEnvironmentId }, + attachResolvedPaths, + setNotice, pendingChips: chips }) let current = true const deliver = probe.latest().captureExternalDrop(() => current) + expect(deliver.preparationConsumer).toBe(runtimeEnvironmentId ? 'agent' : 'local-chat') current = false await act(async () => deliver(['/Users/me/notes.md'])) expect(chips.begun).toEqual([]) expect(mocks.prepareNativeChatSessionAttachmentUpload).not.toHaveBeenCalled() + expect(attachResolvedPaths).not.toHaveBeenCalled() + expect(setNotice).toHaveBeenCalledWith( + 'This workspace changed hosts while attaching — drop the files again.' + ) + await probe.setDisabled(true) + setNotice.mockClear() + await act(async () => deliver(['/Users/me/notes.md'])) + expect(setNotice).not.toHaveBeenCalled() + expect(attachResolvedPaths).not.toHaveBeenCalled() }) it('discards uploaded files if the captured drop destination changed during upload', async () => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts index 60256de4dee..788d65100be 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts @@ -28,6 +28,7 @@ import { userNamedFileAccess } from '@/lib/local-file-access' import { useMountedRef } from '@/hooks/useMountedRef' import { findTerminalTabWorktreeId } from './native-chat-file-link' import { isNativeChatImageAttachmentPath } from './native-chat-image-paste' +import type { CapturedNativeChatDrop } from './native-chat-pane-file-drop' export type UseNativeChatExternalAttachmentsArgs = { terminalTabId: string @@ -82,7 +83,7 @@ export function useNativeChatExternalAttachments({ captureExternalDrop: ( destinationIsCurrent?: () => boolean, options?: NativeChatAttachmentFocusOptions - ) => (paths: string[]) => Promise + ) => CapturedNativeChatDrop captureReservedAttachments: ( destinationIsCurrent?: () => boolean, options?: NativeChatAttachmentFocusOptions @@ -244,7 +245,15 @@ export function useNativeChatExternalAttachments({ } } } - return { chips, refusal, run } + return { + chips, + refusal, + run, + preparationConsumer: + owner.kind === 'local' && owner.structuredSessionId + ? ('local-chat' as const) + : ('agent' as const) + } }, [attachResolvedPaths, resolveAttachmentOwner, setNotice] ) @@ -255,7 +264,7 @@ export function useNativeChatExternalAttachments({ options?: NativeChatAttachmentFocusOptions ) => { const intake = captureIntake(destinationIsCurrent, options) - return async (paths: string[]): Promise => { + const deliver: CapturedNativeChatDrop = async (paths: string[]): Promise => { if (paths.length === 0 || disabledRef.current || !mountedRef.current) { return } @@ -266,6 +275,8 @@ export function useNativeChatExternalAttachments({ } await intake.run(reserveNativeChatAttachmentChips(intake.chips, paths)) } + deliver.preparationConsumer = intake.preparationConsumer + return deliver }, [captureIntake, mountedRef, setNotice] ) diff --git a/src/renderer/src/components/native-chat/use-native-chat-queued-edit-image-intake.ts b/src/renderer/src/components/native-chat/use-native-chat-queued-edit-image-intake.ts index 9285402a9de..56a212bd6ff 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-queued-edit-image-intake.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-queued-edit-image-intake.ts @@ -177,7 +177,7 @@ export function useNativeChatQueuedEditImageIntake( const ownerRef = useRef(null) const [sequence] = useState(createOsFileDropSequence) const attachOwner = useOsFileDropOwner(ownerRef, { - consumer: 'agent', + consumer: (destination) => destination?.preparationConsumer ?? 'agent', sequence, canAccept: () => !disabled, captureDestination: () => captureExternalDrop(), diff --git a/src/renderer/src/hooks/use-os-file-drop-owner.test.tsx b/src/renderer/src/hooks/use-os-file-drop-owner.test.tsx index 6d3af4731c7..99e4b42fcdf 100644 --- a/src/renderer/src/hooks/use-os-file-drop-owner.test.tsx +++ b/src/renderer/src/hooks/use-os-file-drop-owner.test.tsx @@ -7,7 +7,10 @@ import { WORKSPACE_FILE_DRAG_SOURCE_MIME, WORKSPACE_FILE_PATHS_MIME } from '../lib/workspace-file-drag' -import type { PreparedDroppedPaths } from '../../../shared/native-file-drop-preparation' +import type { + PrepareDroppedPathsRequest, + PreparedDroppedPaths +} from '../../../shared/native-file-drop-preparation' import { withFallback } from '../web/preload-api/web-fallback-api' import { createOsFileDropSequence, @@ -22,7 +25,7 @@ type DropHandler = ( const getPathForFile = vi.fn((file: File) => `/dropped/${file.name}`) const prepareDroppedPaths = vi.fn( - async ({ paths }: { paths: string[] }): Promise => ({ + async ({ paths }: PrepareDroppedPathsRequest): Promise => ({ paths, failures: [] }) @@ -33,10 +36,12 @@ function Owner({ canAccept, sequence, captureDestination, + consumer = 'agent', isDestinationLive, children }: { onDrop: DropHandler + consumer?: 'agent' | ((destination: Destination | undefined) => 'local-chat' | 'agent') canAccept?: boolean sequence?: OsFileDropSequence captureDestination?: (event: DragEvent) => Destination @@ -46,7 +51,7 @@ function Owner({ const ownerElementRef = useRef(null) const [ownSequence] = useState(createOsFileDropSequence) const options = { - consumer: 'agent' as const, + consumer, onDrop, canAccept, sequence: sequence ?? ownSequence, @@ -501,7 +506,11 @@ describe('useOsFileDropOwner', () => { ) const onDrop = vi.fn>() const view = render( - + (destination === 'original-folder' ? 'local-chat' : 'agent')} + > ) @@ -509,6 +518,7 @@ describe('useOsFileDropOwner', () => { drag(row, 'drop') expect(captureDestination).toHaveBeenCalledOnce() expect(prepareDroppedPaths).toHaveBeenCalledOnce() + expect(prepareDroppedPaths.mock.calls[0][0].consumer).toBe('local-chat') expect(onDrop).not.toHaveBeenCalled() row.dataset.folder = 'recycled-folder' await act(async () => { diff --git a/src/renderer/src/hooks/use-os-file-drop-owner.ts b/src/renderer/src/hooks/use-os-file-drop-owner.ts index 9a279b4063d..bb54ad81de9 100644 --- a/src/renderer/src/hooks/use-os-file-drop-owner.ts +++ b/src/renderer/src/hooks/use-os-file-drop-owner.ts @@ -25,7 +25,7 @@ type OsFileDropContext = { } type OsFileDropOwnerOptions = { - consumer: DroppedPathConsumer + consumer: DroppedPathConsumer | ((destination: Destination | undefined) => DroppedPathConsumer) sequence: OsFileDropSequence canAccept?: boolean | ((event: DragEvent) => boolean) captureDestination?: (event: DragEvent) => Destination @@ -118,6 +118,8 @@ export function useOsFileDropOwner( if (captureDestination) { context.destination = captureDestination(event) } + const preparationConsumer = + typeof consumer === 'function' ? consumer(context.destination) : consumer const queueDelivery = ( prepared: PreparedDroppedPaths | Promise ): void => { @@ -191,7 +193,10 @@ export function useOsFileDropOwner( const preparation = (async (): Promise => { let prepared: PreparedDroppedPaths try { - prepared = await window.api.fs.prepareDroppedPaths({ paths, consumer }) + prepared = await window.api.fs.prepareDroppedPaths({ + paths, + consumer: preparationConsumer + }) } catch { prepared = { paths: [], diff --git a/src/shared/native-file-drop-preparation.ts b/src/shared/native-file-drop-preparation.ts index 94c553c86cc..f998b7a4e43 100644 --- a/src/shared/native-file-drop-preparation.ts +++ b/src/shared/native-file-drop-preparation.ts @@ -2,7 +2,7 @@ import type { NativeFileDropRejectedPayload } from './native-file-drop' export const OS_FILE_DROP_OWNER_ATTRIBUTE = 'data-os-file-drop-owner' -export type DroppedPathConsumer = 'agent' | 'main-reader' +export type DroppedPathConsumer = 'agent' | 'main-reader' | 'local-chat' export type PrepareDroppedPathsRequest = { paths: string[]