From 9af1ac8d28b7747bbd92d28c6efcbb3854955411 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:10:56 -0700 Subject: [PATCH] A screenshot dragged into a chat lives as long as the chat (#30419) * fix(native-chat): a chat's visuals folder lives as long as the chat A chat's scratch folder was deleted once the chat had gone 30 days unused, so an old chat's inline visuals turned into "Visualization unavailable" while the chat and its history stayed. The common pattern keeps a chat's files for as long as the chat and never deletes them on an "unused for N days" timer. The sweep now removes only folders no chat record maps to, plus leftover .expired folders older builds parked. Everything that existed only to make the age rule safe goes with it: the per-folder save lock and its expiry candidates, the session-lane idle check, the journal last-activity query, and the migration's fresh-window timestamp. The visuals skill no longer tells agents the folder is temporary. * fix(native-chat): an image sent in a local chat lives as long as the chat A local chat's pasted images lived in one paste folder that deleted every file older than 30 days, sent or not, so an old chat's images turned into label chips. Paired-server chats already keep pastes in the host's attachment store, where the send that names a file claims it for its chat and only unclaimed files are ever swept: the common pattern. A local composer paste now goes into this machine's own attachment store (starting the local chat host first), so its send claims it the same way. The old paste folder is no longer written or swept: messages sent before this change may name its files. Restored drafts still read it. An unclaimed upload now waits 30 days instead of one before the sweep takes it. A draft names its image by this path, so the window is how long an unsent draft keeps its image; 30 days is what local drafts had, and it outlasts the host's send-id window so a resent draft still finds its image. A chat folder an older build set aside as unused (.expired) goes back to its chat instead of being deleted. * fix(native-chat): a terminal-backed chat's pastes are never swept A terminal-backed chat has no journal, so no send claims its pastes and nothing can tell a sent one from an abandoned one. Its pastes therefore stay in the paste folder, which is no longer swept, rather than in the attachment store, whose sweep would take them after 30 days unclaimed. Only a local structured chat's paste (forStructuredChat) goes to the store, where its send claims it. * fix(native-chat): keep the attachment owner's literal kind for structured local chats * fix(native-chat): a screenshot dragged into a chat lives as long as the chat On macOS a screenshot dragged from its thumbnail is copied into $TMPDIR/orca-drops, and that copy was deleted 7 days after the drop, so a sent dropped screenshot turned into a label chip after a week. The common pattern keeps a file sent in a chat as long as the chat. A local structured chat's composer now moves each such copy into this machine's chat attachment store, where the send that names it claims it for the chat. Every other copy (terminal panes, terminal-backed chats, editors, startup prompts) has no owner Orca can tell, so copies are no longer swept at all. The copy budget now bounds one drop instead of every copy still on disk: without a sweep, counting earlier copies would refuse every later drop. * style: wrap the drop-copy call * test: drop an unused import * test(ipc): expect the local chat store option on the clipboard registrar * test(ipc): mock the drop-copy registrar in the core handler test * Store fresh chat drops before publishing their paths * Type the drop preparation fixture with its request contract * Capture immutable drop batches before attachment publication * Set explicit buffers for cross-volume attachment imports * Control the deadline in the drop abandonment test * Return void from the drop test timer cleanup * Use the captured attachment destination for queued drops * Register the paired runtime in the queued drop test --- .../agent-session-attachment-store.ts | 63 ++++- .../window/attach-main-window-services.ts | 9 +- .../window/dragged-temp-file-copy.test.ts | 62 +---- src/main/window/dragged-temp-file-copy.ts | 236 +++++++++--------- .../dropped-path-preparation-ipc.test.ts | 14 +- .../window/dropped-path-preparation-ipc.ts | 16 +- .../window/dropped-path-preparation.test.ts | 221 +++++++++++++++- src/main/window/dropped-path-preparation.ts | 98 +++++++- .../NativeChatPaneFileDropSurface.tsx | 2 +- .../NativeChatQueuedMessageEditor.test.tsx | 28 ++- .../native-chat/native-chat-pane-file-drop.ts | 7 +- ...-native-chat-external-attachments.test.tsx | 19 +- .../use-native-chat-external-attachments.ts | 17 +- ...se-native-chat-queued-edit-image-intake.ts | 2 +- .../src/hooks/use-os-file-drop-owner.test.tsx | 18 +- .../src/hooks/use-os-file-drop-owner.ts | 9 +- src/shared/native-file-drop-preparation.ts | 2 +- 17 files changed, 596 insertions(+), 227 deletions(-) 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[]