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 ( <>