mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -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',
|
||||
|
||||
+153
-4
@@ -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
|
||||
)
|
||||
|
||||
+4
@@ -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 }
|
||||
|
||||
+21
-14
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user