From 0f9c12db17cbe2d19802bb4e66d8bf8577902ef3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:38:42 -0700 Subject: [PATCH] Chat: pasting a copied file attaches it, like dropping it (#29089) * fix(native-chat): paste a copied file into Chat the way dropping it attaches it Copying an image file in Finder and pasting into Chat typed the file name and attached nothing: the paste only attached a bitmap from the clipboard. Copied files now go through the composer's drop route, on both the app-menu paste and the paste event. Fixes STA-10652 * refactor(native-chat): share the paste guards with the copied-files branch * test(native-chat): late paste results stay dropped when the image waits for the clipboard read * Keep copied attachments pending through preparation and composition * Update file drop fixture settlement results * Keep pending attachment capability current within its draft scope * fix(native-chat): reserve pending chips before checking dropped files A local file drop checked each file before adding it, with nothing in the composer meanwhile: Send during the check went without the file, which then landed in the next draft; unmounting mid-check lost it; and files queued behind an input-method composition were lost on unmount. Drops now reserve a pending chip per file before the check, so Send waits as it already does for server-upload drops. The prepared attachment travels through the existing composition queue as one operation, settles into its draft scope after unmount, releases its chips when refused or disabled, and reads the scope's current image capability when it applies. * test(native-chat): server-upload chip calls carry no options without paste * refactor(native-chat): move the composer's image chip list into its own hook Main's server-owned paste pushed the attachments hook past the file length limit. * fix(native-chat): a screenshot picture pastes as a picture on macOS, even beside its saved file macOS lists a screenshot tool's saved file beside its picture, so the paste read as a file copy and attached nothing once the file was gone. Only a Finder copy also names its files as text. * fix(native-chat): a macOS right-click paste lets the clipboard tell Finder files from a screenshot The paste event hides Finder's file names, so a path-backed picture reserves its chip at once and the native clipboard decides: named files go to the drop route, a screenshot's picture is saved from the clipboard. Both paste routes now share the clipboard-picture save. * test(native-chat): move the paste hook's test probe into its own fixtures file The paste tests outgrew the test file length limit. * test(native-chat): hand the paste probe's hook result out after render * test(native-chat): give the remote image paste probe the copied-files callback * fix(native-chat): a pending attachment chip shows a spinner and its name, not a stacked icon A local drop now waits as a pending chip, which drew the kind icon, the file name and a centered spinner on top of each other. Without a thumbnail the chip shows the spinner above the name; with one, the dimmed picture keeps a spinner strip along its bottom. * test(native-chat): a pending chip shows its name and spinner, no kind icon * fix(native-chat): a macOS file paste keeps its own chips until the clipboard decides A macOS paste of Finder files reserved one placeholder chip, asked the native clipboard whether it held files or a screenshot picture, and then started a separate attach for the files. Removing the placeholder did not cancel that attach, and leaving the chat before the answer lost the paste. The paste now reserves one chip per copied file before asking, and captures the drop route's intake (owner, destination, chips) at the same moment. Files continue those exact chips through the existing local check or server upload, so removal cancels by the original chip and an unmounted composer still finishes into its draft. A picture keeps the first chip and drops the rest. Ordinary drops and the picker keep their fresh-admission checks. * test(native-chat): a macOS file paste survives removal, unmount and disable through the real intake Drives the real pending cache, composer attachments, external intake and paste hook with a delayed clipboard answer: removed chips never attach (before or after the answer, through input-method composition), an unmount finishes both the local and paired-server routes into the original draft with the original chip IDs, and disabling the composer meanwhile does not bring the paste back. --- .../native-chat/NativeChatComposer.tsx | 29 +- .../native-chat-clipboard-image-save.ts | 26 ++ .../native-chat-clipboard-payload.ts | 51 ++- .../native-chat-composer-paste-options.ts | 8 + ...tive-chat-composer-paste-test-fixtures.tsx | 145 ++++++++ ...e-chat-composer-upload-completion.test.tsx | 7 +- ...-external-attachments-destination.test.tsx | 157 ++++++++- .../native-chat-remote-image-paste.test.tsx | 2 + .../native-chat-resolved-path-ownership.ts | 4 +- .../native-chat-session-attachment-drop.ts | 30 +- .../use-native-chat-composer-attachments.ts | 2 +- ...omposer-paste-session-attachments.test.tsx | 4 + .../use-native-chat-composer-paste.test.tsx | 313 +++++++----------- .../use-native-chat-composer-paste.ts | 263 +++++++-------- ...-native-chat-external-attachments.test.tsx | 35 +- .../use-native-chat-external-attachments.ts | 127 ++++--- .../native-chat/use-native-chat-file-drops.ts | 12 +- ...e-native-chat-menu-paste-lifetime.test.tsx | 2 + .../use-native-chat-paste-lifetime.ts | 79 ++++- ...e-native-chat-resolved-path-attachments.ts | 2 +- 20 files changed, 885 insertions(+), 413 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-paste-test-fixtures.tsx diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index ce8ab5c4ad1..d329ff5f0dc 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -181,19 +181,20 @@ const NativeChatComposerPane = forwardRef boolean + setNotice: (notice: string, errorText?: string) => void + ready?: () => void + } +): ReturnType { + return saveNativeChatClipboardImage(owner, { + ready: paste.ready, + setNotice: (notice, cause, errorText) => { + if ((cause === 'serverTooOld' && paste.besideText) || !paste.canNotify()) { + return + } + if (errorText) { + paste.setNotice(notice, errorText) + } else { + paste.setNotice(notice) + } + } + }) +} diff --git a/src/renderer/src/components/native-chat/native-chat-clipboard-payload.ts b/src/renderer/src/components/native-chat/native-chat-clipboard-payload.ts index 6c3ed75c377..c33e28de4e4 100644 --- a/src/renderer/src/components/native-chat/native-chat-clipboard-payload.ts +++ b/src/renderer/src/components/native-chat/native-chat-clipboard-payload.ts @@ -1,3 +1,5 @@ +import { isMacPlatform } from './native-chat-shortcut' + /** Minimal shape shared by React's synthetic ClipboardEvent and the native DOM * ClipboardEvent — the pane-level listener delivers the native one. */ export type ClipboardEventLike = { @@ -62,14 +64,51 @@ export function clipboardEventPromptText( return textOnlyLabelsCopiedFiles(text, names) ? '' : text } -/** The clipboard's text for a paste with no event, and whether it only labels copied files. */ +/** The files a paste event copied, resolved the way a drop resolves its files; empty unless + * every file has a local path and the event's text only labels them. */ +export function clipboardEventCopiedFilePaths(event: ClipboardEventLike): string[] { + const files = Array.from(event.clipboardData?.files ?? []) + const fs = window.api?.fs + // The web fallback proxy fabricates methods absent from the concrete namespace. + const getPathForFile = fs && 'getPathForFile' in fs ? fs.getPathForFile : undefined + if (files.length === 0 || !getPathForFile) { + return [] + } + const paths: string[] = [] + for (const file of files) { + let path = '' + try { + path = getPathForFile(file) + } catch { + // A pasted bitmap has no file behind it. + } + if (!path) { + return [] + } + paths.push(path) + } + return copiesOnlyFiles(event.clipboardData?.getData('text/plain') ?? '', paths) ? paths : [] +} + +function copiesOnlyFiles(text: string, filePaths: readonly string[]): boolean { + return filePaths.length > 0 && (text === '' || textOnlyLabelsCopiedFiles(text, filePaths)) +} + +/** The clipboard's text, and the files a file manager copied when the text only labels them. + * `eventFilePaths`: the files a paste event already carried, instead of reading them again. */ export async function readClipboardPasteText( - maxBytes: number -): Promise<{ text: string; labelsFiles: boolean }> { + maxBytes: number, + eventFilePaths?: string[] +): Promise<{ text: string; copiedFilePaths: string[] }> { const [text, filePaths] = await Promise.all([ window.api.ui.readClipboardText({ maxBytes }), - // Bookkeeping only: without the file list the text is typed as-is. - window.api.ui.readClipboardFilePaths().catch(() => []) + // Without the file list the text is typed as-is. + eventFilePaths ?? window.api.ui.readClipboardFilePaths().catch(() => []) ]) - return { text, labelsFiles: text !== '' && textOnlyLabelsCopiedFiles(text, filePaths) } + if (!copiesOnlyFiles(text, filePaths)) { + return { text, copiedFilePaths: [] } + } + // macOS lists a screenshot tool's saved file beside its picture; only Finder also names files. + const picture = text === '' && isMacPlatform() && (await window.api.ui.clipboardHasImage()) + return { text, copiedFilePaths: picture ? [] : filePaths } } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-paste-options.ts b/src/renderer/src/components/native-chat/native-chat-composer-paste-options.ts index dfbbe834ca8..195c60863e3 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-paste-options.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-paste-options.ts @@ -1,5 +1,6 @@ import type { AgentType } from '../../../../shared/agent-status-types' import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' +import type { NativeChatReservedAttachment } from './native-chat-session-attachment-drop' export type UseNativeChatComposerPasteArgs = { targetKey?: string @@ -12,6 +13,13 @@ export type UseNativeChatComposerPasteArgs = { * host, or the attached path names a file the agent cannot read. */ resolveAttachmentOwner: () => NativeChatAttachmentOwner attachResolvedPaths: (paths: string[], connectionId?: string | null) => void + /** Files a file manager copied attach through the composer's drop route. */ + attachCopiedFiles: (paths: string[], options?: { focus?: boolean }) => void + /** Captured at paste time: continues chips a paste reserved through that route once the + * clipboard shows they are files, even if the composer unmounted meanwhile. */ + captureCopiedFiles: (options?: { + focus?: boolean + }) => (reserved: NativeChatReservedAttachment[]) => Promise /** `serverOwned`: an image the paste saved on the remote target's own server, not a client file. */ beginPendingImageAttachment: ( previewUrl?: string, diff --git a/src/renderer/src/components/native-chat/native-chat-composer-paste-test-fixtures.tsx b/src/renderer/src/components/native-chat/native-chat-composer-paste-test-fixtures.tsx new file mode 100644 index 00000000000..06d036c8c4e --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-paste-test-fixtures.tsx @@ -0,0 +1,145 @@ +import { act, createElement, useLayoutEffect } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' +import type { NativeChatReservedAttachment } from './native-chat-session-attachment-drop' +import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' + +type HookApi = ReturnType + +/** Mirrors the composer's attachment list so tests can assert what the user sees. */ +type FakeChip = { id: string; path: string; previewUrl?: string; pending: boolean } + +export function createChipStore(): { + chips: FakeChip[] + begin: (previewUrl?: string) => string | null + resolve: (id: string, path: string, connectionId?: string | null) => void + reveal: (id: string, previewUrl?: string) => void + drop: (id: string) => void + connectionIds: (string | null | undefined)[] +} { + const chips: FakeChip[] = [] + const connectionIds: (string | null | undefined)[] = [] + let counter = 0 + return { + chips, + connectionIds, + reveal: (id, previewUrl) => { + const chip = chips.find((candidate) => candidate.id === id) + if (chip) { + chip.previewUrl = previewUrl + } + }, + begin: (previewUrl) => { + counter += 1 + const id = `chip-${counter}` + chips.push({ id, path: '', previewUrl, pending: true }) + return id + }, + resolve: (id, path, connectionId) => { + const chip = chips.find((candidate) => candidate.id === id) + if (chip) { + chip.path = path + chip.pending = false + } + connectionIds.push(connectionId) + }, + drop: (id) => { + const index = chips.findIndex((candidate) => candidate.id === id) + if (index !== -1) { + chips.splice(index, 1) + } + } + } +} + +type ProbeArgs = { + targetKey?: string + agent?: 'claude' | 'omp' + disabled: boolean + resolveAttachmentOwner: () => NativeChatAttachmentOwner + attachResolvedPaths: (paths: string[], connectionId?: string | null) => void + attachCopiedFiles: (paths: string[]) => void + captureCopiedFiles: () => (reserved: NativeChatReservedAttachment[]) => Promise + beginPendingImageAttachment: (previewUrl?: string) => string | null + resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void + revealPendingImageAttachment: (id: string, previewUrl?: string) => void + dropPendingImageAttachment: (id: string) => void + insertTypedText: (text: string) => boolean + setNotice: (notice: string | null) => void + onReady: (api: HookApi) => void +} + +function Probe({ onReady, ...args }: ProbeArgs): null { + const api = useNativeChatComposerPaste({ agent: 'claude', caret: 0, setCaret: () => {}, ...args }) + useLayoutEffect(() => onReady(api)) + return null +} + +let root: Root | null = null + +export function unmountPasteProbe(): void { + root?.unmount() + root = null +} + +export async function renderProbe(args: { + agent?: 'claude' | 'omp' + disabled?: boolean + resolveAttachmentOwner: () => NativeChatAttachmentOwner + attachResolvedPaths?: (paths: string[], connectionId?: string | null) => void + attachCopiedFiles?: (paths: string[]) => void + /** Receives the chips a macOS paste reserved, once the clipboard shows they are files. */ + continueCopiedFiles?: (reserved: NativeChatReservedAttachment[]) => Promise + store?: ReturnType + insertTypedText?: (text: string) => boolean + setNotice?: (notice: string | null) => void +}): Promise<{ + latest: () => HookApi + setDisabled: (disabled: boolean) => Promise + setTarget: (key: string) => Promise +}> { + const container = document.createElement('div') + document.body.append(container) + const store = args.store ?? createChipStore() + let api: HookApi | null = null + root = createRoot(container) + let targetKey = 'session-1' + const render = async (disabled: boolean): Promise => { + await act(async () => { + root?.render( + createElement(Probe, { + targetKey, + agent: args.agent ?? 'claude', + disabled, + resolveAttachmentOwner: args.resolveAttachmentOwner, + attachResolvedPaths: args.attachResolvedPaths ?? (() => {}), + attachCopiedFiles: args.attachCopiedFiles ?? (() => {}), + captureCopiedFiles: () => args.continueCopiedFiles ?? (async () => {}), + beginPendingImageAttachment: args.agent === 'omp' ? () => null : store.begin, + resolvePendingImageAttachment: store.resolve, + revealPendingImageAttachment: store.reveal, + dropPendingImageAttachment: store.drop, + insertTypedText: args.insertTypedText ?? (() => true), + setNotice: args.setNotice ?? (() => {}), + onReady: (next) => { + api = next + } + }) + ) + }) + } + await render(args.disabled ?? false) + return { + latest: () => { + if (!api) { + throw new Error('Probe did not render') + } + return api + }, + setDisabled: render, + setTarget: async (key) => { + targetKey = key + await render(false) + } + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-upload-completion.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-upload-completion.test.tsx index 97d06b69b52..01b3872e4b5 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-upload-completion.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-upload-completion.test.tsx @@ -3,7 +3,10 @@ import { createRef } from 'react' import { act, cleanup, render, renderHook } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' -import { attachNativeChatSessionAttachmentPaths } from './native-chat-session-attachment-drop' +import { + attachNativeChatSessionAttachmentPaths, + reserveNativeChatAttachmentChips +} from './native-chat-session-attachment-drop' import { NativeChatPromptEditor } from './NativeChatPromptEditor' import type { NativeChatComposerInput } from './native-chat-composer-input' import { @@ -116,7 +119,7 @@ async function uploadFixture( let uploaded = Promise.resolve() act(() => { uploaded = attachNativeChatSessionAttachmentPaths({ - paths: [sourcePath], + pending: reserveNativeChatAttachmentChips(view.result.current.pendingChips, [sourcePath]), owner: { kind: 'runtime-session', environmentId: 'paired', diff --git a/src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx b/src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx index b30174c2bde..83d16137199 100644 --- a/src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx @@ -8,6 +8,7 @@ import { nativeChatPendingAttachmentSnapshot } from './native-chat-pending-attac import { nativeChatImagesHoldSend } from './native-chat-image-reattach' import { readNativeChatDraftCache } from './native-chat-draft-cache' import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' +import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' import { clearNativeChatAttachmentCacheForTests, useNativeChatComposerAttachments @@ -18,11 +19,15 @@ const mocks = vi.hoisted(() => { owner: NativeChatAttachmentOwner remoteTarget: boolean stat: ReturnType + readText: ReturnType + upload: ReturnType tabsByWorktree: Record } = { owner: { kind: 'local' }, remoteTarget: false, stat: vi.fn(), + readText: vi.fn(), + upload: vi.fn(), tabsByWorktree: { 'workspace-1': [{ id: 'tab-1' }] } } return state @@ -37,11 +42,13 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({ ...(await importOriginal()), resolveNativeChatAttachmentOwner: () => mocks.owner, - resolveNativeChatAttachmentOwnerForWorktree: () => mocks.owner + resolveNativeChatAttachmentOwnerForWorktree: () => mocks.owner, + prepareNativeChatSessionAttachmentUpload: async () => ({ ok: true, target: {} }) })) type Api = ReturnType & - ReturnType + ReturnType & + ReturnType let api: Api | undefined let root: Root let container: HTMLDivElement @@ -80,7 +87,24 @@ function Probe({ pendingChips: attachments.pendingChips, setNotice }) - api = { ...attachments, ...external } + const paste = useNativeChatComposerPaste({ + attachmentScopeKey: 'drop-test', + agent: 'claude', + disabled, + caret, + setCaret, + resolveAttachmentOwner: external.resolveAttachmentOwner, + attachResolvedPaths: attachments.attachResolvedPaths, + attachCopiedFiles: external.attachExternalPaths, + captureCopiedFiles: (options) => external.captureReservedAttachments(undefined, options), + beginPendingImageAttachment: attachments.beginPendingImageAttachment, + resolvePendingImageAttachment: attachments.pendingChips.resolve, + revealPendingImageAttachment: attachments.revealPendingImageAttachment, + dropPendingImageAttachment: attachments.dropPendingImageAttachment, + insertTypedText: () => true, + setNotice + }) + api = { ...attachments, ...external, ...paste } return ( <>