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.
This commit is contained in:
Brennan Benson
2026-10-17 00:38:42 -07:00
committed by GitHub
parent ee56703d51
commit 0f9c12db17
20 changed files with 885 additions and 413 deletions
@@ -181,19 +181,20 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
? !hasPty || !onStop
: sendHeld || (draft.trim() === '' && imageAttachments.length === 0)
const { pickAttachments, resolveAttachmentOwner } = useNativeChatFileDrops({
paneKey,
draftScopeKey,
targetPtyId,
structuredTransport,
terminalTabId,
structuredWorktreeId: structuredTransport?.worktreeId,
structuredSession: structuredTransport,
disabled,
attachResolvedPaths,
pendingChips: attachments.pendingChips,
setNotice
})
const { pickAttachments, attachExternalPaths, captureCopiedFiles, resolveAttachmentOwner } =
useNativeChatFileDrops({
paneKey,
draftScopeKey,
targetPtyId,
structuredTransport,
terminalTabId,
structuredWorktreeId: structuredTransport?.worktreeId,
structuredSession: structuredTransport,
disabled,
attachResolvedPaths,
pendingChips: attachments.pendingChips,
setNotice
})
const handlePasteEvent = useNativeChatComposerHandle(ref, {
attachmentScopeKey: draftScopeKey,
@@ -214,6 +215,8 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
disabled,
resolveAttachmentOwner,
attachResolvedPaths,
attachCopiedFiles: attachExternalPaths,
captureCopiedFiles,
beginPendingImageAttachment: attachments.beginPendingImageAttachment,
// Settles into the scope cache, which outlives a composer a prompt card unmounted.
resolvePendingImageAttachment: attachments.pendingChips.resolve,
@@ -90,3 +90,29 @@ export async function saveNativeChatClipboardImage(
return { status: 'failed' }
}
}
/** A paste's save. Beside pasted text, a server too old to store the image drops only the image
* rendition quietly, as an owner that takes no images does. */
export function saveNativeChatPastedClipboardImage(
owner: NativeChatAttachmentOwner,
paste: {
besideText: boolean
canNotify: () => boolean
setNotice: (notice: string, errorText?: string) => void
ready?: () => void
}
): ReturnType<typeof saveNativeChatClipboardImage> {
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)
}
}
})
}
@@ -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 }
}
@@ -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<void>
/** `serverOwned`: an image the paste saved on the remote target's own server, not a client file. */
beginPendingImageAttachment: (
previewUrl?: string,
@@ -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<typeof useNativeChatComposerPaste>
/** 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<void>
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<void>
store?: ReturnType<typeof createChipStore>
insertTypedText?: (text: string) => boolean
setNotice?: (notice: string | null) => void
}): Promise<{
latest: () => HookApi
setDisabled: (disabled: boolean) => Promise<void>
setTarget: (key: string) => Promise<void>
}> {
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<void> => {
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)
}
}
}
@@ -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',
@@ -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<typeof vi.fn>
readText: ReturnType<typeof vi.fn>
upload: ReturnType<typeof vi.fn>
tabsByWorktree: Record<string, { id: string }[]>
} = {
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<typeof AttachmentUploadModule>()),
resolveNativeChatAttachmentOwner: () => mocks.owner,
resolveNativeChatAttachmentOwnerForWorktree: () => mocks.owner
resolveNativeChatAttachmentOwnerForWorktree: () => mocks.owner,
prepareNativeChatSessionAttachmentUpload: async () => ({ ok: true, target: {} })
}))
type Api = ReturnType<typeof useNativeChatExternalAttachments> &
ReturnType<typeof useNativeChatComposerAttachments>
ReturnType<typeof useNativeChatComposerAttachments> &
ReturnType<typeof useNativeChatComposerPaste>
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 (
<>
<textarea ref={textareaRef} />
@@ -110,7 +134,14 @@ beforeEach(() => {
mocks.remoteTarget = false
mocks.stat.mockReset().mockResolvedValue(undefined)
mocks.tabsByWorktree = { 'workspace-1': [{ id: 'tab-1' }] }
vi.stubGlobal('api', { fs: { stat: mocks.stat } })
vi.stubGlobal('api', {
fs: {
stat: mocks.stat,
getPathForFile: (file: File) => `/Users/me/${file.name}`,
uploadPathsToAgentSessionAttachments: mocks.upload
},
ui: { readClipboardText: mocks.readText, readClipboardImageThumbnail: async () => null }
})
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
@@ -243,6 +274,22 @@ describe('OS attachment destination through input-method composition', () => {
expect(readNativeChatDraftCache('drop-test')).toBe('@/notes.txt')
})
it('preserves paste focus for references and image conversion, with drop focus as the default', async () => {
composing = false
await render('workspace-1', false)
const other = document.createElement('input')
container.append(other)
other.focus()
await act(async () => latest().attachExternalPaths(['/a.png', '/notes.txt'], { focus: false }))
await new Promise((resolve) => requestAnimationFrame(resolve))
expect(document.activeElement).toBe(other)
expect(container.querySelector('[data-draft]')?.textContent).toContain('@/a.png')
expect(container.querySelector('[data-draft]')?.textContent).toContain('@/notes.txt')
await act(async () => latest().attachExternalPaths(['/drop.txt']))
await new Promise((resolve) => requestAnimationFrame(resolve))
expect(document.activeElement).toBe(container.querySelector('textarea'))
})
it('settles prepared pending work quietly when composition loses its composer', async () => {
await render('workspace-1')
await act(async () => latest().attachExternalPaths(['/a.png', '/notes.txt', '/removed.png']))
@@ -291,3 +338,105 @@ describe('OS attachment destination through input-method composition', () => {
expect(container.querySelector('[data-draft]')?.textContent).toBe('')
})
})
describe('a macOS paste the clipboard shows is Finder files', () => {
let answer: (text: string) => void = () => {}
beforeEach(() => {
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Macintosh)')
mocks.readText.mockReset().mockReturnValue(
new Promise<string>((resolve) => {
answer = resolve
})
)
})
afterEach(() => vi.restoreAllMocks())
function pasteFinderCopy(names: string[]): void {
const data = new DataTransfer()
for (const name of names) {
data.items.add(new File(['x'], name, { type: name.endsWith('.png') ? 'image/png' : '' }))
}
act(() =>
latest().handlePaste(new ClipboardEvent('paste', { clipboardData: data, cancelable: true }))
)
}
async function answerClipboard(text: string): Promise<void> {
await act(async () => {
answer(text)
await new Promise((resolve) => setTimeout(resolve, 0))
})
}
function chipNamed(name: string): string {
const chip = latest().imageAttachments.find(({ pendingName }) => pendingName === name)
if (!chip) {
throw new Error(`Missing reserved chip ${name}`)
}
return chip.id
}
it('attaches nothing the user removed, before or after the clipboard answered', async () => {
await render('workspace-1')
pasteFinderCopy(['a.png', 'notes.txt', 'late.txt'])
expect(nativeChatImagesHoldSend(latest().imageAttachments)).toBe(true)
act(() => latest().removeImageAttachment(chipNamed('a.png')))
await answerClipboard('a.png\nnotes.txt\nlate.txt')
// References wait for the input-method composition, and Send waits with them.
expect(container.querySelector('[data-draft]')?.textContent).toBe('')
expect(nativeChatImagesHoldSend(latest().imageAttachments)).toBe(true)
act(() => latest().removeImageAttachment(chipNamed('late.txt')))
composing = false
act(() => latest().flushPendingAttachments())
expect(container.querySelector('[data-draft]')?.textContent).toBe('@/Users/me/notes.txt ')
expect(latest().imageAttachments).toEqual([])
expect(mocks.stat).toHaveBeenCalledTimes(2)
})
it.each(['local', 'runtime-session'])(
'finishes a %s paste into its draft when the composer unmounts before the clipboard answers',
async (route) => {
const stored = route === 'local' ? '/Users/me' : '/srv/store'
if (route === 'runtime-session') {
mocks.owner = {
kind: 'runtime-session',
environmentId: 'env-1',
pairingRevision: 1,
sessionId: 's'
}
mocks.upload.mockResolvedValue({
uploaded: ['a.png', 'notes.txt'].map((name) => ({
sourcePath: `/Users/me/${name}`,
path: `${stored}/${name}`
})),
skipped: [],
failed: []
})
}
await render('workspace-1')
pasteFinderCopy(['a.png', 'notes.txt'])
const ids = nativeChatPendingAttachmentSnapshot('drop-test').map(({ id }) => id)
expect(new Set(ids).size).toBe(2)
act(() => root.unmount())
await answerClipboard('a.png\nnotes.txt')
expect(nativeChatPendingAttachmentSnapshot('drop-test')).toEqual([])
root = createRoot(container)
await render('workspace-1')
expect(latest().imageAttachments).toEqual([
expect.objectContaining({ id: ids[0], path: `${stored}/a.png` })
])
expect(readNativeChatDraftCache('drop-test')).toBe(`@${stored}/notes.txt`)
}
)
it('does not bring back a paste that disabling the composer cleared', async () => {
composing = false
await render('workspace-1')
pasteFinderCopy(['a.png', 'notes.txt'])
await render('workspace-1', true, true)
await render('workspace-1')
await answerClipboard('a.png\nnotes.txt')
expect(nativeChatPendingAttachmentSnapshot('drop-test')).toEqual([])
expect(latest().imageAttachments).toEqual([])
expect(container.querySelector('[data-draft]')?.textContent).toBe('')
expect(mocks.stat).not.toHaveBeenCalled()
})
})
@@ -84,6 +84,8 @@ function Probe({
caret,
resolveAttachmentOwner: owner,
attachResolvedPaths: attachments.attachResolvedPaths,
attachCopiedFiles: () => {},
captureCopiedFiles: () => async () => {},
beginPendingImageAttachment: attachments.beginPendingImageAttachment,
resolvePendingImageAttachment: attachments.resolvePendingImageAttachment,
revealPendingImageAttachment: attachments.revealPendingImageAttachment,
@@ -1,12 +1,14 @@
import { translate } from '@/i18n/i18n'
import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload'
export type NativeChatAttachmentFocusOptions = { focus?: boolean }
export type NativeChatResolvedPathOperation = {
apply: (insertReferences: ((paths: string[]) => void) | null) => boolean
cancel: () => void
}
export type NativeChatResolvedPathOptions = {
export type NativeChatResolvedPathOptions = NativeChatAttachmentFocusOptions & {
operation?: NativeChatResolvedPathOperation
/** Revalidates the destination without granting workspace-file provenance. */
destinationIsCurrent?: () => boolean
@@ -2,6 +2,7 @@ import { basename } from '@/lib/path'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import { describeDropSkipReason } from '@/lib/drop-skip-reason-copy'
import { isNativeChatImageAttachmentPath } from './native-chat-image-paste'
import type { NativeChatAttachmentFocusOptions } from './native-chat-resolved-path-ownership'
import {
nativeChatAttachFailedNotice,
nativeChatAttachmentOwnerChangedNotice,
@@ -10,7 +11,7 @@ import {
type NativeChatRuntimeSessionAttachmentOwner
} from './native-chat-attachment-upload'
export type NativeChatPendingAttachmentOptions = {
export type NativeChatPendingAttachmentOptions = NativeChatAttachmentFocusOptions & {
insertReferences?: ((paths: string[]) => void) | null
}
@@ -33,6 +34,20 @@ export type NativeChatPendingAttachmentChips = {
) => boolean
}
/** A file and the pending chip reserved for it, which alone may settle its result. */
export type NativeChatReservedAttachment = { path: string; chipId: string }
/** One named pending chip per file; a path without one was refused and the composer said why. */
export function reserveNativeChatAttachmentChips(
chips: NativeChatPendingAttachmentChips,
paths: string[]
): NativeChatReservedAttachment[] {
return paths.flatMap((path) => {
const chipId = chips.begin(undefined, basename(path))
return chipId ? [{ path, chipId }] : []
})
}
/**
* Drop or pick files into a structured chat on a paired server: upload them into the chat's store
* there, then attach the stored paths. Each file shows as a pending chip with its name at once, and
@@ -40,19 +55,16 @@ export type NativeChatPendingAttachmentChips = {
* the user removes meanwhile attaches nothing.
*/
export async function attachNativeChatSessionAttachmentPaths(args: {
paths: string[]
pending: NativeChatReservedAttachment[]
owner: NativeChatRuntimeSessionAttachmentOwner
chips: NativeChatPendingAttachmentChips
options?: NativeChatAttachmentFocusOptions
/** The composer was disabled or torn down meanwhile. */
isAbandoned: () => boolean
ownerStillCurrent: () => boolean
setNotice: (notice: string | null) => void
}): Promise<void> {
const pending = args.paths.flatMap((path) => {
const chipId = args.chips.begin(undefined, basename(path))
// No chip: the composer refused the attach and already said why.
return chipId ? [{ path, chipId }] : []
})
const { pending } = args
if (pending.length === 0) {
return
}
@@ -108,7 +120,7 @@ export async function attachNativeChatSessionAttachmentPaths(args: {
continue
}
if (isNativeChatImageAttachmentPath(storedPath)) {
args.chips.resolve(chipId, storedPath, null)
args.chips.resolve(chipId, storedPath, null, args.options)
continue
}
// Other files become `@path` references, as every attach does, unless their chip was removed.
@@ -116,7 +128,7 @@ export async function attachNativeChatSessionAttachmentPaths(args: {
}
// Inserting at the caret clears the notice, so what failed is said after.
if (references.length > 0) {
args.chips.attachReferences(references)
args.chips.attachReferences(references, args.options)
}
if (notAttached.length > 0) {
// A cause every file shares is said once, after their names.
@@ -201,7 +201,7 @@ export function useNativeChatComposerAttachments({
attachResolvedPaths(
paths,
null,
serverOwned ? { targetOwnerIsCurrent: () => true } : undefined
serverOwned ? { ...options, targetOwnerIsCurrent: () => true } : options
)
: undefined
)
@@ -87,6 +87,8 @@ async function renderPaste(args: {
setCaret: () => {},
resolveAttachmentOwner: () => sessionOwner,
attachResolvedPaths: args.attachResolvedPaths ?? (() => {}),
attachCopiedFiles: () => {},
captureCopiedFiles: () => async () => {},
beginPendingImageAttachment: () => {
counter += 1
chips.push({
@@ -274,6 +276,8 @@ describe('pasting into a structured chat on a paired server', () => {
setCaret: () => {},
resolveAttachmentOwner: () => sessionOwner,
attachResolvedPaths: () => {},
attachCopiedFiles: () => {},
captureCopiedFiles: () => async () => {},
beginPendingImageAttachment: () => 'chip-1',
// The composer's chips file a result that lands after it unmounted into its scope cache.
resolvePendingImageAttachment: (id, path) => resolved.push(`${id} ${path}`),
@@ -1,7 +1,6 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react'
import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload'
const mocks = vi.hoisted(() => ({
@@ -9,7 +8,8 @@ const mocks = vi.hoisted(() => ({
readClipboardText: vi.fn(),
readClipboardImageThumbnail: vi.fn(),
clipboardHasImage: vi.fn(),
readClipboardFilePaths: vi.fn()
readClipboardFilePaths: vi.fn(),
getPathForFile: vi.fn()
}))
vi.mock('@/i18n/i18n', () => ({
@@ -34,7 +34,8 @@ vi.stubGlobal('window', {
readClipboardImageThumbnail: mocks.readClipboardImageThumbnail,
clipboardHasImage: mocks.clipboardHasImage,
readClipboardFilePaths: mocks.readClipboardFilePaths
}
},
fs: { getPathForFile: mocks.getPathForFile }
}
})
vi.stubGlobal('URL', {
@@ -42,134 +43,11 @@ vi.stubGlobal('URL', {
revokeObjectURL: () => {}
})
import { useNativeChatComposerPaste } from './use-native-chat-composer-paste'
type HookApi = ReturnType<typeof useNativeChatComposerPaste>
/** Mirrors the composer's attachment list so tests can assert what the user sees. */
type FakeChip = { id: string; path: string; previewUrl?: string; pending: boolean }
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
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 {
onReady(useNativeChatComposerPaste({ agent: 'claude', caret: 0, setCaret: () => {}, ...args }))
return null
}
let root: Root | null = null
async function renderProbe(args: {
agent?: 'claude' | 'omp'
disabled?: boolean
resolveAttachmentOwner: () => NativeChatAttachmentOwner
attachResolvedPaths?: (paths: string[], connectionId?: string | null) => void
store?: ReturnType<typeof createChipStore>
insertTypedText?: (text: string) => boolean
setNotice?: (notice: string | null) => void
}): Promise<{
latest: () => HookApi
setDisabled: (disabled: boolean) => Promise<void>
setTarget: (key: string) => Promise<void>
}> {
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<void> => {
await act(async () => {
root?.render(
createElement(Probe, {
targetKey,
agent: args.agent ?? 'claude',
disabled,
resolveAttachmentOwner: args.resolveAttachmentOwner,
attachResolvedPaths: args.attachResolvedPaths ?? (() => {}),
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)
}
}
}
import {
createChipStore,
renderProbe,
unmountPasteProbe
} from './native-chat-composer-paste-test-fixtures'
function imagePasteEvent(text = ''): ClipboardEvent {
const data = new DataTransfer()
@@ -182,16 +60,18 @@ function imagePasteEvent(text = ''): ClipboardEvent {
beforeEach(() => {
vi.resetAllMocks()
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (X11; Linux x86_64)')
mocks.readClipboardText.mockResolvedValue('')
mocks.readClipboardImageThumbnail.mockResolvedValue(null)
mocks.clipboardHasImage.mockResolvedValue(true)
mocks.readClipboardFilePaths.mockResolvedValue([])
mocks.getPathForFile.mockReturnValue('')
mocks.saveClipboardImageAsTempFile.mockResolvedValue(null)
})
afterEach(() => {
root?.unmount()
root = null
unmountPasteProbe()
vi.restoreAllMocks()
vi.clearAllMocks()
})
@@ -565,11 +445,6 @@ describe('composer paste intake regressions', () => {
async (change) => {
let finishText = (_text: string): void => {}
let finishImage = (_path: string): void => {}
mocks.readClipboardText.mockReturnValue(
new Promise<string>((resolve) => {
finishText = resolve
})
)
mocks.saveClipboardImageAsTempFile.mockReturnValue(
new Promise<string>((resolve) => {
finishImage = resolve
@@ -587,12 +462,18 @@ describe('composer paste intake regressions', () => {
insertTypedText,
store
})
// An image still saving, then a second paste whose text read is still in flight.
await act(async () => probe.latest().pasteFromClipboard())
expect(store.chips).toHaveLength(1)
mocks.readClipboardText.mockReturnValue(
new Promise<string>((resolve) => {
finishText = resolve
})
)
await act(async () => probe.latest().pasteFromClipboard())
if (change === 'unmount') {
await act(async () => {
root?.unmount()
root = null
unmountPasteProbe()
})
} else if (change === 'replace') {
await probe.setTarget('session-2')
@@ -773,41 +654,113 @@ describe('file-manager copies', () => {
expect(insertTypedText).not.toHaveBeenCalled()
})
describe('from the app menu (macOS Cmd+V)', () => {
it.each([
['a single file', 'shot.png', ['/Users/me/Desktop/shot.png']],
['several files', 'a.png\rb.pdf', ['/Users/me/a.png', '/Users/me/b.pdf']]
])('attaches %s without typing the Finder label', async (_label, text, paths) => {
mocks.readClipboardText.mockResolvedValue(text)
mocks.readClipboardFilePaths.mockResolvedValue(paths)
it('attaches pasted files that have local paths as a drop would, typing nothing', async () => {
mocks.getPathForFile.mockImplementation((file: File) => `/Users/me/${file.name}`)
const insertTypedText = vi.fn(() => true)
const attachCopiedFiles = vi.fn()
const probe = await renderProbe({
resolveAttachmentOwner: () => ({ kind: 'local' }),
insertTypedText,
attachCopiedFiles
})
const files = [png('shot.png'), new File(['pdf'], 'b.pdf', { type: 'application/pdf' })]
await act(async () => probe.latest().handlePaste(fileCopyEvent(files, 'shot.png\nb.pdf')))
expect(attachCopiedFiles).toHaveBeenCalledExactlyOnceWith(
['/Users/me/shot.png', '/Users/me/b.pdf'],
{ focus: false }
)
expect(insertTypedText).not.toHaveBeenCalled()
expect(mocks.saveClipboardImageAsTempFile).not.toHaveBeenCalled()
})
it.each([
['a screenshot picture beside its saved file', '', false],
['a Finder copy, whose clipboard text names the file', 'Screenshot.png', true]
])(
'on macOS, holds a chip at once for %s, then lets the clipboard decide',
async (_label, nativeText, attachesFiles) => {
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Macintosh)')
mocks.getPathForFile.mockImplementation((file: File) => `/Users/me/Desktop/${file.name}`)
let finishRead: (text: string) => void = () => {}
mocks.readClipboardText.mockReturnValue(
new Promise<string>((resolve) => {
finishRead = resolve
})
)
mocks.saveClipboardImageAsTempFile.mockResolvedValue('/tmp/shot.png')
const insertTypedText = vi.fn(() => true)
const attachResolvedPaths = vi.fn()
const attachCopiedFiles = vi.fn()
const continueCopiedFiles = vi.fn(async () => {})
const store = createChipStore()
const probe = await renderProbe({
resolveAttachmentOwner: () => ({ kind: 'local' }),
insertTypedText,
attachResolvedPaths,
attachCopiedFiles,
continueCopiedFiles,
store
})
await act(async () => probe.latest().pasteFromClipboard())
expect(insertTypedText).not.toHaveBeenCalled()
expect(store.chips[0]).toMatchObject({ path: '/tmp/shot.png', pending: false })
expect(attachResolvedPaths).not.toHaveBeenCalled()
})
await act(async () => probe.latest().handlePaste(fileCopyEvent([png('Screenshot.png')], '')))
expect(store.chips).toEqual([expect.objectContaining({ pending: true })])
await act(async () => finishRead(nativeText))
// Files continue the chip reserved at paste time; no new attach starts.
expect(attachCopiedFiles).not.toHaveBeenCalled()
expect(continueCopiedFiles.mock.calls).toEqual(
attachesFiles ? [[[{ path: '/Users/me/Desktop/Screenshot.png', chipId: 'chip-1' }]]] : []
)
expect(store.chips).toEqual([
attachesFiles
? expect.objectContaining({ id: 'chip-1', pending: true })
: expect.objectContaining({ path: '/tmp/shot.png', pending: false })
])
expect(mocks.saveClipboardImageAsTempFile).toHaveBeenCalledTimes(attachesFiles ? 0 : 1)
}
)
it('types the label when no image came with the files', async () => {
mocks.clipboardHasImage.mockResolvedValue(false)
mocks.readClipboardText.mockResolvedValue('notes.txt')
mocks.readClipboardFilePaths.mockResolvedValue(['/Users/me/notes.txt'])
const insertTypedText = vi.fn(() => true)
const probe = await renderProbe({
resolveAttachmentOwner: () => ({ kind: 'local' }),
insertTypedText
})
await act(async () => probe.latest().pasteFromClipboard())
expect(insertTypedText).toHaveBeenCalledExactlyOnceWith('notes.txt')
})
describe('from the app menu (macOS Cmd+V)', () => {
it.each([
['a single file', 'shot.png', ['/Users/me/Desktop/shot.png'], true],
['several files', 'a.png\rb.pdf', ['/Users/me/a.png', '/Users/me/b.pdf'], true],
['a file with no image rendition', 'notes.txt', ['/Users/me/notes.txt'], false]
])(
'attaches %s as a drop would, without typing the Finder label',
async (_label, text, paths, hasImage) => {
mocks.readClipboardText.mockResolvedValue(text)
mocks.readClipboardFilePaths.mockResolvedValue(paths)
mocks.clipboardHasImage.mockResolvedValue(hasImage)
const insertTypedText = vi.fn(() => true)
const attachCopiedFiles = vi.fn()
const probe = await renderProbe({
resolveAttachmentOwner: () => ({ kind: 'local' }),
insertTypedText,
attachCopiedFiles
})
await act(async () => probe.latest().pasteFromClipboard())
expect(attachCopiedFiles).toHaveBeenCalledExactlyOnceWith(paths, { focus: false })
expect(insertTypedText).not.toHaveBeenCalled()
// The copied file itself is attached, never the bitmap rendition beside it.
expect(mocks.saveClipboardImageAsTempFile).not.toHaveBeenCalled()
}
)
it.each([
['macOS', 'Macintosh', false],
['other platforms', 'X11; Linux x86_64', true]
])(
'on %s, treats a screenshot picture beside its saved file as files only off macOS',
async (_label, platform, attachesFiles) => {
// A screenshot tool's clipboard: the picture plus its saved file, which may be gone.
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(`Mozilla/5.0 (${platform})`)
mocks.readClipboardText.mockResolvedValue('')
mocks.readClipboardFilePaths.mockResolvedValue(['/Users/me/Desktop/Screenshot.png'])
mocks.saveClipboardImageAsTempFile.mockResolvedValue('/tmp/shot.png')
const attachCopiedFiles = vi.fn()
const probe = await renderProbe({
resolveAttachmentOwner: () => ({ kind: 'local' }),
attachCopiedFiles
})
await act(async () => probe.latest().pasteFromClipboard())
expect(attachCopiedFiles).toHaveBeenCalledTimes(attachesFiles ? 1 : 0)
expect(mocks.saveClipboardImageAsTempFile).toHaveBeenCalledTimes(attachesFiles ? 0 : 1)
}
)
it('types unrelated text at once, and when the file list cannot be read', async () => {
mocks.readClipboardText.mockResolvedValue('see attached')
@@ -826,27 +779,5 @@ describe('file-manager copies', () => {
await act(async () => probe.latest().pasteFromClipboard())
expect(insertTypedText).toHaveBeenLastCalledWith('shot.png')
})
it.each([
[true, 0, 1],
[false, 1, 0]
])(
'on a remote runtime, image presence %s decides between the refusal and the label',
async (hasImage, inserts, notices) => {
mocks.readClipboardText.mockResolvedValue('shot.png')
mocks.readClipboardFilePaths.mockResolvedValue(['/Users/me/shot.png'])
mocks.clipboardHasImage.mockResolvedValue(hasImage)
const insertTypedText = vi.fn(() => true)
const setNotice = vi.fn()
const probe = await renderProbe({
resolveAttachmentOwner: () => ({ kind: 'remote' }),
insertTypedText,
setNotice
})
await act(async () => probe.latest().pasteFromClipboard())
expect(insertTypedText).toHaveBeenCalledTimes(inserts)
expect(setNotice.mock.calls.filter(([notice]) => notice !== null)).toHaveLength(notices)
}
)
})
})
@@ -5,7 +5,9 @@ import { assertClipboardTextWithinLimit } from '../../../../shared/clipboard-tex
import { setNativeChatPasteFailure } from './native-chat-composer-notice'
import { NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES } from './native-chat-composer-target'
import { nativeChatPasteUnavailableNotice } from '@/lib/native-chat-paste-request'
import { isMacPlatform } from './native-chat-shortcut'
import {
clipboardEventCopiedFilePaths,
clipboardEventImageFile,
clipboardEventPromptText,
readClipboardPasteText,
@@ -18,7 +20,7 @@ import {
} from './native-chat-attachment-upload'
import {
ownerAcceptsClipboardImage,
saveNativeChatClipboardImage
saveNativeChatPastedClipboardImage
} from './native-chat-clipboard-image-save'
import type { UseNativeChatComposerPasteArgs } from './native-chat-composer-paste-options'
@@ -31,6 +33,8 @@ export function useNativeChatComposerPaste({
caret,
resolveAttachmentOwner,
attachResolvedPaths,
attachCopiedFiles,
captureCopiedFiles,
beginPendingImageAttachment,
resolvePendingImageAttachment,
revealPendingImageAttachment,
@@ -46,15 +50,21 @@ export function useNativeChatComposerPaste({
useLayoutEffect(() => {
disabledRef.current = disabled
}, [disabled])
const { lifetime, track, startImageChip, keepStoreUploadAfterUnmount } =
useNativeChatPasteLifetime({
targetKey,
attachmentScopeKey,
beginPendingImageAttachment,
resolvePendingImageAttachment,
revealPendingImageAttachment,
dropPendingImageAttachment
})
const {
lifetime,
startImageChip,
startClipboardImageChip,
startClipboardCandidateChips,
dropImagePaste,
keepStoreUploadAfterUnmount
} = useNativeChatPasteLifetime({
targetKey,
attachmentScopeKey,
beginPendingImageAttachment,
resolvePendingImageAttachment,
revealPendingImageAttachment,
dropPendingImageAttachment
})
const canPaste = useCallback(() => lifetime.active && !disabledRef.current, [lifetime])
// A disabled composer still answers a paste, so it never vanishes silently.
const showPasteUnavailable = useCallback(() => {
@@ -62,43 +72,6 @@ export function useNativeChatComposerPaste({
setNotice(nativeChatPasteUnavailableNotice())
}
}, [lifetime, setNotice])
const dropImagePaste = useCallback(
(pendingId: string | null) => {
if (pendingId) {
lifetime.pending.delete(pendingId)
dropPendingImageAttachment(pendingId)
}
},
[lifetime, dropPendingImageAttachment]
)
// Image failures do not decide whether text can be inserted. Beside pasted text, a server too old
// to store the image drops only the image rendition, as an owner that takes no images does.
const saveClipboardImageForOwner = useCallback(
(
owner: NativeChatAttachmentOwner,
besidePastedText: () => Promise<boolean>,
ready?: () => void
) =>
saveNativeChatClipboardImage(owner, {
ready,
setNotice: (notice, cause, errorText) => {
void (cause === 'serverTooOld' ? besidePastedText() : Promise.resolve(false)).then(
(quiet) => {
if (quiet || !canPaste()) {
return
}
if (errorText) {
setNotice(notice, errorText)
} else {
setNotice(notice)
}
}
)
}
}),
[canPaste, setNotice]
)
/** Settle the operation started at paste time, or attach directly if chips are unavailable. */
const settleImagePaste = useCallback(
@@ -125,6 +98,33 @@ export function useNativeChatComposerPaste({
]
)
// Image failures do not decide whether text can be inserted. Resolves whether the image settled.
const saveClipboardImage = useCallback(
async (
owner: NativeChatAttachmentOwner,
pendingId: string | null,
besideText: boolean,
ready?: () => void
): Promise<boolean> => {
const saved = await saveNativeChatPastedClipboardImage(owner, {
besideText,
ready,
canNotify: canPaste,
setNotice
})
if (keepStoreUploadAfterUnmount(pendingId, saved)) {
return false
}
if (!canPaste() || saved.status !== 'saved') {
dropImagePaste(pendingId)
return false
}
settleImagePaste(pendingId, saved.tempPath, owner)
return true
},
[canPaste, dropImagePaste, keepStoreUploadAfterUnmount, setNotice, settleImagePaste]
)
const handlePaste = useCallback(
(event: ClipboardEventLike) => {
// Dedupe: the pane-level capture listener runs first and preventDefaults
@@ -132,9 +132,12 @@ export function useNativeChatComposerPaste({
if (event.defaultPrevented) {
return
}
const imageFile = clipboardEventImageFile(event)
const text = clipboardEventPromptText(event, imageFile !== null)
if (!imageFile && !text) {
// Copied files attach themselves, never a picture or label pasted beside them.
const copiedFilePaths = clipboardEventCopiedFilePaths(event)
const copiesFiles = copiedFilePaths.length > 0
const imageFile = copiesFiles ? null : clipboardEventImageFile(event)
const text = copiesFiles ? '' : clipboardEventPromptText(event, imageFile !== null)
if (!copiesFiles && !imageFile && !text) {
return
}
event.preventDefault()
@@ -143,6 +146,46 @@ export function useNativeChatComposerPaste({
return
}
setNotice(null)
const macOwner =
copiesFiles && isMacPlatform() && clipboardEventImageFile(event)
? resolveAttachmentOwner()
: null
// This event hides Finder's file names, so the native clipboard tells a file copy from a
// screenshot's picture. One chip per file holds Send meanwhile, and whichever wins settles
// those same chips, so removing one cancels it and an unmount still finishes into the draft.
if (macOwner && ownerAcceptsClipboardImage(macOwner)) {
const chips = startClipboardCandidateChips(macOwner, copiedFilePaths)
const [primary, ...extras] = chips.reserved
if (!primary) {
return
}
const continueFiles = captureCopiedFiles({ focus: false })
void readClipboardPasteText(NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES, copiedFilePaths).then(
(read) => {
if (read.copiedFilePaths.length > 0) {
void continueFiles(chips.transfer())
return
}
extras.forEach(({ chipId }) => dropImagePaste(chipId))
if (read.text && canPaste()) {
insertTypedText(read.text)
}
chips.reveal()
void saveClipboardImage(macOwner, primary.chipId, read.text !== '')
},
(error) => {
chips.reserved.forEach(({ chipId }) => dropImagePaste(chipId))
if (canPaste()) {
setNativeChatPasteFailure(setNotice, error)
}
}
)
return
}
if (copiesFiles) {
attachCopiedFiles(copiedFilePaths, { focus: false })
return
}
if (text) {
try {
assertClipboardTextWithinLimit(text, { maxBytes: NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES })
@@ -174,38 +217,31 @@ export function useNativeChatComposerPaste({
deferPreview: awaitServer,
canShow: () => ownerAcceptsClipboardImage(owner) && canPaste()
})
void (async () => {
const saved = await saveClipboardImageForOwner(
owner,
async () => Boolean(text),
awaitServer ? reveal : undefined
)
if (keepStoreUploadAfterUnmount(pendingId, saved)) {
return
}
if (saved.status !== 'saved' || !canPaste()) {
dropImagePaste(pendingId)
return
}
settleImagePaste(pendingId, saved.tempPath, owner)
if (!text) {
void saveClipboardImage(
owner,
pendingId,
Boolean(text),
awaitServer ? reveal : undefined
).then((settled) => {
if (settled && !text) {
setCaret(caretAtPaste)
}
})()
})
},
[
attachCopiedFiles,
canPaste,
captureCopiedFiles,
caret,
dropImagePaste,
insertTypedText,
showPasteUnavailable,
resolveAttachmentOwner,
keepStoreUploadAfterUnmount,
startImageChip,
saveClipboardImageForOwner,
startClipboardCandidateChips,
saveClipboardImage,
setCaret,
setNotice,
settleImagePaste
setNotice
]
)
@@ -220,33 +256,32 @@ export function useNativeChatComposerPaste({
showPasteUnavailable()
}
}
// Text belongs to the editor even when the attachment host is unavailable.
// Text that only labels copied files waits for the image outcome instead.
const textRead = readClipboardPasteText(NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES)
.then((read) => {
if (!read.labelsFiles) {
insertText(read.text)
}
return read
})
.catch((error) => {
if (canPaste()) {
setNativeChatPasteFailure(setNotice, error)
}
return null
})
void (async () => {
const read = await readClipboardPasteText(NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES).catch(
(error) => {
if (canPaste()) {
setNativeChatPasteFailure(setNotice, error)
}
return null
}
)
// A file manager's copy pastes the files themselves, exactly as dropping them does.
if (read && read.copiedFilePaths.length > 0) {
if (canPaste()) {
attachCopiedFiles(read.copiedFilePaths, { focus: false })
}
return
}
// Text belongs to the editor even when the attachment host is unavailable.
insertText(read?.text ?? '')
const owner = resolveAttachmentOwner()
if (!ownerAcceptsClipboardImage(owner)) {
const read = await textRead
// Probe only when no text was typed: in a browser each clipboard read can prompt the user.
if (!read || (read.text !== '' && !read.labelsFiles)) {
if (!read || read.text !== '') {
return
}
const hasImage = await window.api.ui.clipboardHasImage().catch(() => null)
if (!hasImage) {
insertText(read.text)
} else if (canPaste()) {
if (hasImage && canPaste()) {
setNotice(
owner.kind === 'remote'
? nativeChatLocalAttachmentUnsupportedNotice()
@@ -262,59 +297,25 @@ export function useNativeChatComposerPaste({
return null
})
if (hasImage !== true) {
const read = await textRead
if (read?.labelsFiles) {
insertText(read.text)
}
return
}
if (!canPaste()) {
return
}
// Ownership starts before saving; a thumbnail only supplies its preview.
const pendingId = beginPendingImageAttachment(undefined, undefined, owner.kind === 'remote')
if (pendingId) {
track(pendingId, '', owner)
}
void window.api.ui
.readClipboardImageThumbnail()
.catch(() => null)
.then((thumbnail) => {
if (pendingId && thumbnail) {
revealPendingImageAttachment?.(pendingId, thumbnail.dataUrl)
}
})
const saved = await saveClipboardImageForOwner(owner, async () => {
const read = await textRead
return Boolean(read?.text) && read?.labelsFiles !== true
})
if (keepStoreUploadAfterUnmount(pendingId, saved)) {
return
}
if (!canPaste() || saved.status !== 'saved') {
dropImagePaste(pendingId)
// A file's label is typed only when no image came with it.
const read = saved.status === 'empty' ? await textRead : null
if (read?.labelsFiles) {
insertText(read.text)
}
return
}
settleImagePaste(pendingId, saved.tempPath, owner)
const { id: pendingId, reveal } = startClipboardImageChip(owner)
reveal()
await saveClipboardImage(owner, pendingId, Boolean(read?.text))
})()
}, [
beginPendingImageAttachment,
revealPendingImageAttachment,
attachCopiedFiles,
canPaste,
dropImagePaste,
insertTypedText,
keepStoreUploadAfterUnmount,
track,
startClipboardImageChip,
saveClipboardImage,
showPasteUnavailable,
resolveAttachmentOwner,
saveClipboardImageForOwner,
setNotice,
settleImagePaste
setNotice
])
return { handlePaste, pasteFromClipboard }
@@ -427,12 +427,14 @@ describe('useNativeChatExternalAttachments', () => {
expect(chips.resolve).toHaveBeenCalledExactlyOnceWith(
'chip-1',
'/srv/agent-session-attachments/u1/shot.png',
null
null,
undefined
)
expect(chips.drop).not.toHaveBeenCalled()
expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([
{ id: 'chip-2', path: '/srv/agent-session-attachments/u2/notes.md' }
])
expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith(
[{ id: 'chip-2', path: '/srv/agent-session-attachments/u2/notes.md' }],
undefined
)
// The client never reads its own disk for these paths: the server stores the bytes.
expect(mocks.stat).not.toHaveBeenCalled()
})
@@ -494,9 +496,10 @@ describe('useNativeChatExternalAttachments', () => {
uploaded([['/Users/me/notes.md', '/srv/agent-session-attachments/u1/notes.md']])
)
)
expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([
{ id: 'chip-1', path: '/srv/agent-session-attachments/u1/notes.md' }
])
expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith(
[{ id: 'chip-1', path: '/srv/agent-session-attachments/u1/notes.md' }],
undefined
)
})
it('keeps each file operation ID through reference settlement, including removed chips', async () => {
@@ -522,10 +525,13 @@ describe('useNativeChatExternalAttachments', () => {
)
)
expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([
{ id: 'chip-1', path: '/srv/agent-session-attachments/u1/report.pdf' },
{ id: 'chip-2', path: '/srv/agent-session-attachments/u2/notes.md' }
])
expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith(
[
{ id: 'chip-1', path: '/srv/agent-session-attachments/u1/report.pdf' },
{ id: 'chip-2', path: '/srv/agent-session-attachments/u2/notes.md' }
],
undefined
)
})
it('names, in one notice, each file that did not attach', async () => {
@@ -551,9 +557,10 @@ describe('useNativeChatExternalAttachments', () => {
)
expect(chips.drop).toHaveBeenCalledWith('chip-1')
expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([
{ id: 'chip-2', path: '/srv/agent-session-attachments/u2/notes.md' }
])
expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith(
[{ id: 'chip-2', path: '/srv/agent-session-attachments/u2/notes.md' }],
undefined
)
// The cause, the size limit here, rides in the same notice.
expect(notices).toEqual([
"Couldn't attach huge.mov. 'huge.mov' is 60 MB, over the 50 MB chat attachment limit."
@@ -1,9 +1,9 @@
import { useCallback, useLayoutEffect, useRef } from 'react'
import { useAppStore } from '@/store'
import { basename } from '@/lib/path'
import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop'
import {
nativeChatAttachmentOwnerUnchanged,
type NativeChatAttachmentFocusOptions,
type NativeChatResolvedPathOptions
} from './native-chat-resolved-path-ownership'
import {
@@ -20,7 +20,9 @@ import {
} from './native-chat-attachment-upload'
import {
attachNativeChatSessionAttachmentPaths,
type NativeChatPendingAttachmentChips
reserveNativeChatAttachmentChips,
type NativeChatPendingAttachmentChips,
type NativeChatReservedAttachment
} from './native-chat-session-attachment-drop'
import { userNamedFileAccess } from '@/lib/local-file-access'
import { useMountedRef } from '@/hooks/useMountedRef'
@@ -75,9 +77,16 @@ export function useNativeChatExternalAttachments({
pendingChips,
setNotice
}: UseNativeChatExternalAttachmentsArgs): {
attachExternalPaths: (paths: string[]) => void
attachExternalPaths: (paths: string[], options?: NativeChatAttachmentFocusOptions) => void
resolveAttachmentOwner: () => NativeChatAttachmentOwner
captureExternalDrop: (destinationIsCurrent?: () => boolean) => (paths: string[]) => Promise<void>
captureExternalDrop: (
destinationIsCurrent?: () => boolean,
options?: NativeChatAttachmentFocusOptions
) => (paths: string[]) => Promise<void>
captureReservedAttachments: (
destinationIsCurrent?: () => boolean,
options?: NativeChatAttachmentFocusOptions
) => (reserved: NativeChatReservedAttachment[]) => Promise<void>
} {
const mountedRef = useMountedRef()
const disabledRef = useRef(disabled)
@@ -120,9 +129,11 @@ export function useNativeChatExternalAttachments({
: resolveNativeChatAttachmentOwner(useAppStore.getState(), terminalTabId)
}, [])
const captureExternalDrop = useCallback(
(destinationIsCurrent: () => boolean = () => true) => {
// Captures one intake's owner, destination and chips; its checks and work run against them later.
const captureIntake = useCallback(
(destinationIsCurrent: () => boolean, options?: NativeChatAttachmentFocusOptions) => {
const owner = resolveAttachmentOwner()
const chips = pendingChipsRef.current
const capturedWorkspace = workspaceRef.current
const currentWorktreeId = (): string | null =>
workspaceRef.current.structuredWorktreeId ??
@@ -136,51 +147,37 @@ export function useNativeChatExternalAttachments({
isSameComposerWorkspace(capturedWorkspace, workspaceRef.current) &&
capturedWorktreeId === currentWorktreeId() &&
nativeChatAttachmentOwnerUnchanged(owner, resolveAttachmentOwner())
return async (paths: string[]): Promise<void> => {
if (paths.length === 0 || disabledRef.current || !mountedRef.current) {
return
}
const refusal = (count: number): string | null => {
if (!destinationIsCurrent()) {
setNotice(nativeChatAttachmentOwnerChangedNotice())
return
return nativeChatAttachmentOwnerChangedNotice()
}
if (owner.kind === 'not-ready') {
setNotice(nativeChatWorktreeNotReadyNotice())
return
return nativeChatWorktreeNotReadyNotice()
}
if (owner.kind === 'remote') {
setNotice(nativeChatLocalAttachmentUnsupportedNotice())
return
return nativeChatLocalAttachmentUnsupportedNotice()
}
// The picker shares the drop limit without partially attaching a batch.
if (paths.length > NATIVE_FILE_DROP_MAX_PATHS) {
setNotice(nativeChatTooManyAttachmentsNotice())
return
}
if (!ownerStillCurrent()) {
setNotice(nativeChatAttachmentOwnerChangedNotice())
return
if (count > NATIVE_FILE_DROP_MAX_PATHS) {
return nativeChatTooManyAttachmentsNotice()
}
return ownerStillCurrent() ? null : nativeChatAttachmentOwnerChangedNotice()
}
const run = async (reserved: NativeChatReservedAttachment[]): Promise<void> => {
if (owner.kind === 'runtime-session') {
await attachNativeChatSessionAttachmentPaths({
paths,
pending: reserved,
owner,
chips: pendingChipsRef.current,
chips,
options,
isAbandoned: () => disabledRef.current,
ownerStillCurrent,
setNotice
})
return
}
const chips = pendingChipsRef.current
const pending: { path: string; chipId: string }[] = []
const pending = [...reserved]
try {
for (const path of paths) {
const chipId = chips.begin(undefined, basename(path))
if (chipId) {
pending.push({ path, chipId })
}
}
if (pending.length === 0) {
return
}
@@ -217,10 +214,11 @@ export function useNativeChatExternalAttachments({
readable.map(({ path }) => path),
undefined,
{
...options,
destinationIsCurrent: ownerStillCurrent,
operation: {
apply: (insertReferences) => {
const completion = { insertReferences }
const completion = { focus: false, insertReferences }
let attached = false
for (const { path, chipId } of readable) {
attached =
@@ -242,17 +240,70 @@ export function useNativeChatExternalAttachments({
}
}
}
return { chips, refusal, run }
},
[attachResolvedPaths, mountedRef, resolveAttachmentOwner, setNotice]
[attachResolvedPaths, resolveAttachmentOwner, setNotice]
)
const captureExternalDrop = useCallback(
(
destinationIsCurrent: () => boolean = () => true,
options?: NativeChatAttachmentFocusOptions
) => {
const intake = captureIntake(destinationIsCurrent, options)
return async (paths: string[]): Promise<void> => {
if (paths.length === 0 || disabledRef.current || !mountedRef.current) {
return
}
const notice = intake.refusal(paths.length)
if (notice) {
setNotice(notice)
return
}
await intake.run(reserveNativeChatAttachmentChips(intake.chips, paths))
}
},
[captureIntake, mountedRef, setNotice]
)
// Chips a paste reserved before its clipboard said they are files: admitted work, so it may
// finish after an unmount; a refusal releases them.
const captureReservedAttachments = useCallback(
(
destinationIsCurrent: () => boolean = () => true,
options?: NativeChatAttachmentFocusOptions
) => {
const intake = captureIntake(destinationIsCurrent, options)
return async (reserved: NativeChatReservedAttachment[]): Promise<void> => {
if (reserved.length === 0) {
return
}
const notice = disabledRef.current ? '' : intake.refusal(reserved.length)
if (notice === null) {
await intake.run(reserved)
return
}
reserved.forEach(({ chipId }) => intake.chips.drop(chipId))
if (notice) {
setNotice(notice)
}
}
},
[captureIntake, setNotice]
)
const attachExternalPaths = useCallback(
(paths: string[]): void => {
(paths: string[], options?: NativeChatAttachmentFocusOptions): void => {
if (!disabledRef.current && mountedRef.current && paths.length > 0) {
void captureExternalDrop()(paths)
void captureExternalDrop(undefined, options)(paths)
}
},
[captureExternalDrop, mountedRef]
)
return { attachExternalPaths, captureExternalDrop, resolveAttachmentOwner }
return {
attachExternalPaths,
captureExternalDrop,
captureReservedAttachments,
resolveAttachmentOwner
}
}
@@ -1,4 +1,4 @@
import { useLayoutEffect, useRef } from 'react'
import { useCallback, useLayoutEffect, useRef } from 'react'
import type { NativeChatComposerProps } from './native-chat-composer-types'
import {
useNativeChatExternalAttachments,
@@ -7,6 +7,7 @@ import {
import { useNativeChatWorkspaceFileDrop } from './use-native-chat-workspace-file-drop'
import { useNativeChatFileAttachmentActions } from './use-native-chat-file-attachment-actions'
import { useNativeChatPaneFileDropClaim } from './NativeChatPaneFileDropSurface'
import type { NativeChatAttachmentFocusOptions } from './native-chat-resolved-path-ownership'
type Args = UseNativeChatExternalAttachmentsArgs &
Pick<NativeChatComposerProps, 'paneKey' | 'draftScopeKey' | 'targetPtyId' | 'structuredTransport'>
@@ -33,8 +34,17 @@ export function useNativeChatFileDrops(args: Args) {
captureExternalDrop: () =>
external.captureExternalDrop(() => destinationRef.current === destinationKey)
})
const { captureReservedAttachments } = external
// A paste's files keep this destination, which a later retarget or deletion cancels.
const captureCopiedFiles = useCallback(
(options?: NativeChatAttachmentFocusOptions) =>
captureReservedAttachments(() => destinationRef.current === destinationKey, options),
[captureReservedAttachments, destinationKey]
)
return {
...useNativeChatFileAttachmentActions(external.attachExternalPaths),
attachExternalPaths: external.attachExternalPaths,
captureCopiedFiles,
resolveAttachmentOwner: external.resolveAttachmentOwner
}
}
@@ -95,6 +95,8 @@ async function fixture(acceptsImages = true, source: 'menu' | 'event' = 'menu')
disabled: false,
caret: 0,
resolveAttachmentOwner: () => owner,
attachCopiedFiles: () => {},
captureCopiedFiles: () => async () => {},
...attachments,
insertTypedText: () => true,
setCaret: () => {},
@@ -1,5 +1,7 @@
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
import { basename } from '@/lib/path'
import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload'
import type { NativeChatReservedAttachment } from './native-chat-session-attachment-drop'
import { nativeChatPendingAttachmentSnapshot } from './native-chat-pending-attachment-cache'
/**
@@ -27,6 +29,19 @@ export function useNativeChatPasteLifetime(args: {
imageFile: Blob,
options: { deferPreview: boolean; canShow: () => boolean }
) => { id: string | null; reveal: () => void }
/** Register the operation for the native clipboard's picture; `reveal` previews its thumbnail. */
startClipboardImageChip: (owner: NativeChatAttachmentOwner, pendingName?: string) => {
id: string | null
reveal: () => void
}
/** One chip per file a macOS paste may attach. `reveal` previews the first as the clipboard's
* picture; `transfer` hands the chips still in the draft to the file intake to settle. */
startClipboardCandidateChips: (owner: NativeChatAttachmentOwner, paths: string[]) => {
reserved: NativeChatReservedAttachment[]
reveal: () => void
transfer: () => NativeChatReservedAttachment[]
}
dropImagePaste: (pendingId: string | null) => void
keepStoreUploadAfterUnmount: (
pendingId: string | null,
saved: { status: string; tempPath?: string }
@@ -125,6 +140,60 @@ export function useNativeChatPasteLifetime(args: {
},
[attachmentScopeKey, beginPendingImageAttachment, lifetime, revealPendingImageAttachment, track]
)
const startClipboardImageChip = useCallback(
(
owner: NativeChatAttachmentOwner,
pendingName?: string
): { id: string | null; reveal: () => void } => {
const id = beginPendingImageAttachment(undefined, pendingName, owner.kind === 'remote')
if (id) {
track(id, '', owner)
}
const reveal = (): void => {
void window.api.ui
.readClipboardImageThumbnail()
.catch(() => null)
.then((thumbnail) => {
if (id && thumbnail) {
revealPendingImageAttachment?.(id, thumbnail.dataUrl)
}
})
}
return { id, reveal }
},
[beginPendingImageAttachment, revealPendingImageAttachment, track]
)
const startClipboardCandidateChips = useCallback(
(owner: NativeChatAttachmentOwner, paths: string[]) => {
const chips = paths.flatMap((path) => {
const chip = startClipboardImageChip(owner, basename(path))
return chip.id ? [{ path, chipId: chip.id, reveal: chip.reveal }] : []
})
const reserved = chips.map(({ path, chipId }) => ({ path, chipId }))
const transfer = (): NativeChatReservedAttachment[] => {
for (const { chipId } of reserved) {
lifetime.pending.delete(chipId)
lifetime.uploads.delete(chipId)
}
// A chip removed or cleared meanwhile starts no file work.
const current = attachmentScopeKey
? nativeChatPendingAttachmentSnapshot(attachmentScopeKey)
: null
return reserved.filter(({ chipId }) => !current || current.some(({ id }) => id === chipId))
}
return { reserved, reveal: () => chips[0]?.reveal(), transfer }
},
[attachmentScopeKey, lifetime, startClipboardImageChip]
)
const dropImagePaste = useCallback(
(pendingId: string | null) => {
if (pendingId) {
lifetime.pending.delete(pendingId)
dropPendingImageAttachment(pendingId)
}
},
[lifetime, dropPendingImageAttachment]
)
const keepStoreUploadAfterUnmount = useCallback(
(pendingId: string | null, saved: { status: string; tempPath?: string }): boolean => {
if (!pendingId || lifetime.active) {
@@ -156,5 +225,13 @@ export function useNativeChatPasteLifetime(args: {
},
[attachmentScopeKey, dropPendingImageAttachment, lifetime, resolvePendingImageAttachment]
)
return { lifetime, track, startImageChip, keepStoreUploadAfterUnmount }
return {
lifetime,
track,
startImageChip,
startClipboardImageChip,
startClipboardCandidateChips,
dropImagePaste,
keepStoreUploadAfterUnmount
}
}
@@ -262,7 +262,7 @@ export function useNativeChatResolvedPathAttachments({
operation
}
operation = undefined
applyResolvedPaths([batch], true, false, !mountedRef.current)
applyResolvedPaths([batch], options.focus !== false, false, !mountedRef.current)
} finally {
operation?.cancel()
}