mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Keep picked skills and pending uploads when native chat composers close (#26538)
* Preserve native chat skills and uploads when composers close * Own menu image pastes before saving begins * Keep paste ownership independent of image presentation * Show pending pastes so stalled saves can be removed * Register confirmed image pastes and preserve preview ownership * Keep clipboard image presence and saving on one source selector * Describe clipboard image formats in upload routing fixtures
This commit is contained in:
@@ -10,7 +10,11 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: vi.fn(() => '/tmp/client-user-data') },
|
||||
clipboard: { readImage: mocks.readImage },
|
||||
clipboard: {
|
||||
readImage: mocks.readImage,
|
||||
availableFormats: () => ['image/png'],
|
||||
readBuffer: () => Buffer.alloc(0)
|
||||
},
|
||||
ipcMain: { removeHandler: vi.fn(), handle: mocks.handle },
|
||||
nativeImage: { createFromBuffer: vi.fn() }
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { readClipboardImageSource } from './clipboard-image-source'
|
||||
|
||||
function clipboardReader(filePath = '', itemCount = 1, formats: string[] = ['FileNameW']) {
|
||||
const shellItems = Buffer.alloc(4 + 4 * (itemCount + 1))
|
||||
shellItems.writeUInt32LE(itemCount)
|
||||
const buffers: Record<string, Buffer> = {
|
||||
FileNameW: Buffer.from(`${filePath}\0`, 'utf16le'),
|
||||
'Shell IDList Array': shellItems
|
||||
}
|
||||
return {
|
||||
availableFormats: vi.fn(() => formats),
|
||||
readBuffer: vi.fn((format: string) => buffers[format] ?? Buffer.alloc(0))
|
||||
}
|
||||
}
|
||||
|
||||
describe('readClipboardImageSource', () => {
|
||||
it.each(['darwin', 'linux', 'win32'] as const)(
|
||||
'recognizes native image formats on %s without decoding',
|
||||
(platform) => {
|
||||
const clipboard = clipboardReader('', 1, ['text/plain', 'image/png'])
|
||||
expect(readClipboardImageSource(clipboard, platform)).toEqual({
|
||||
kind: 'native',
|
||||
windowsFileFormats: null
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['darwin', 'linux', 'win32'] as const)('ignores ordinary text on %s', (platform) => {
|
||||
expect(readClipboardImageSource(clipboardReader('', 1, ['text/plain']), platform)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'C:\\Users\\alice\\图片\\shot.PNG',
|
||||
'\\\\server\\share\\shot.jpeg',
|
||||
'\\\\?\\C:\\Users\\alice\\shot.jpg',
|
||||
'\\\\?\\UNC\\server\\share\\shot.png'
|
||||
])('recognizes a supported single Windows image file: %s', (filePath) => {
|
||||
const clipboard = clipboardReader(filePath)
|
||||
const source = readClipboardImageSource(clipboard, 'win32')
|
||||
expect(source?.kind).toBe('windows-file')
|
||||
expect(source?.windowsFileFormats).toEqual({
|
||||
fileNameW: clipboard.readBuffer('FileNameW'),
|
||||
shellIdListArray: clipboard.readBuffer('Shell IDList Array')
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['C:\\shot.pdf', 1],
|
||||
['C:\\shot.webp', 1],
|
||||
['C:\\shot.png', 2],
|
||||
['shot.png', 1],
|
||||
['C:shot.png', 1],
|
||||
['\\\\server\\pipe\\shot.png', 1],
|
||||
['C:\\one.png\0C:\\two.png', 1]
|
||||
])('rejects an unsupported file source: %s (%s items)', (filePath, itemCount) => {
|
||||
expect(readClipboardImageSource(clipboardReader(filePath, itemCount), 'win32')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['darwin', 'linux'] as const)('never reads Windows file formats on %s', (platform) => {
|
||||
const clipboard = clipboardReader('C:\\shot.png')
|
||||
expect(readClipboardImageSource(clipboard, platform)).toBeNull()
|
||||
expect(clipboard.readBuffer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the copied-file fallback beside advertised native image data', () => {
|
||||
const clipboard = clipboardReader('C:\\shot.png', 1, ['image/png', 'FileNameW'])
|
||||
const source = readClipboardImageSource(clipboard, 'win32')
|
||||
expect(source?.kind).toBe('native')
|
||||
expect(source?.windowsFileFormats?.fileNameW).toEqual(clipboard.readBuffer('FileNameW'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { clipboardFormatsIncludeImage } from '../../shared/clipboard-image'
|
||||
import {
|
||||
readWindowsCopiedImageFilePath,
|
||||
type WindowsClipboardFileFormats
|
||||
} from './clipboard-windows-image-file'
|
||||
|
||||
type ClipboardImageReader = {
|
||||
availableFormats: () => string[]
|
||||
readBuffer: (format: string) => Buffer
|
||||
}
|
||||
|
||||
type ClipboardImageSource =
|
||||
| { kind: 'native'; windowsFileFormats: WindowsClipboardFileFormats | null }
|
||||
| { kind: 'windows-file'; windowsFileFormats: WindowsClipboardFileFormats }
|
||||
|
||||
/** Select image sources without decoding pixels or reading a copied file. */
|
||||
export function readClipboardImageSource(
|
||||
clipboard: ClipboardImageReader,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): ClipboardImageSource | null {
|
||||
const formats =
|
||||
platform === 'win32'
|
||||
? {
|
||||
fileNameW: clipboard.readBuffer('FileNameW'),
|
||||
shellIdListArray: clipboard.readBuffer('Shell IDList Array')
|
||||
}
|
||||
: null
|
||||
const windowsFileFormats = formats && readWindowsCopiedImageFilePath(formats) ? formats : null
|
||||
if (clipboardFormatsIncludeImage(clipboard.availableFormats())) {
|
||||
return { kind: 'native', windowsFileFormats }
|
||||
}
|
||||
return windowsFileFormats ? { kind: 'windows-file', windowsFileFormats } : null
|
||||
}
|
||||
@@ -226,6 +226,7 @@ describe('registerClipboardHandlers', () => {
|
||||
clipboardReadBufferMock.mockReset()
|
||||
clipboardReadBufferMock.mockReturnValue(Buffer.alloc(0))
|
||||
clipboardWriteTextMock.mockReset()
|
||||
clipboardAvailableFormatsMock.mockReturnValue(['image/png'])
|
||||
clipboardReadImageMock.mockReset()
|
||||
clipboardWriteImageMock.mockReset()
|
||||
clipboardWriteBufferMock.mockReset()
|
||||
@@ -571,62 +572,72 @@ describe('registerClipboardHandlers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('routes a Windows Explorer FileNameW image through the target-aware attachment flow', async () => {
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
const sourcePath = 'C:\\Users\\alice\\图片\\copied-image.png'
|
||||
const png = Buffer.from([4, 3, 2, 1])
|
||||
clipboardReadImageMock.mockReturnValue({ isEmpty: () => true })
|
||||
clipboardReadBufferMock.mockImplementation((format: string) =>
|
||||
format === 'FileNameW' ? Buffer.from(`${sourcePath}\0`, 'utf16le') : shellIdListArray(1)
|
||||
)
|
||||
const source = Buffer.alloc(24)
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(source)
|
||||
source.writeUInt32BE(13, 8)
|
||||
source.write('IHDR', 12, 'ascii')
|
||||
source.writeUInt32BE(1, 16)
|
||||
source.writeUInt32BE(1, 20)
|
||||
const close = vi.fn().mockResolvedValue(undefined)
|
||||
fsOpenMock.mockResolvedValue({
|
||||
close,
|
||||
stat: vi.fn().mockResolvedValue({ isFile: () => true, size: source.byteLength }),
|
||||
read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => {
|
||||
const bytesRead = Math.min(Math.max(source.byteLength - position, 0), length)
|
||||
source.copy(buffer, offset, position, position + bytesRead)
|
||||
return { buffer, bytesRead }
|
||||
it.each([false, true])(
|
||||
'routes a Windows Explorer image with native format advertised: %s',
|
||||
async (advertisesImage) => {
|
||||
setTrustedClipboardRendererWebContentsId(17)
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
clipboardAvailableFormatsMock.mockReturnValue(
|
||||
advertisesImage ? ['image/png', 'FileNameW'] : ['FileNameW']
|
||||
)
|
||||
const sourcePath = 'C:\\Users\\alice\\图片\\copied-image.png'
|
||||
const png = Buffer.from([4, 3, 2, 1])
|
||||
clipboardReadImageMock.mockReturnValue({ isEmpty: () => true })
|
||||
clipboardReadBufferMock.mockImplementation((format: string) =>
|
||||
format === 'FileNameW' ? Buffer.from(`${sourcePath}\0`, 'utf16le') : shellIdListArray(1)
|
||||
)
|
||||
const source = Buffer.alloc(24)
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(source)
|
||||
source.writeUInt32BE(13, 8)
|
||||
source.write('IHDR', 12, 'ascii')
|
||||
source.writeUInt32BE(1, 16)
|
||||
source.writeUInt32BE(1, 20)
|
||||
const close = vi.fn().mockResolvedValue(undefined)
|
||||
fsOpenMock.mockResolvedValue({
|
||||
close,
|
||||
stat: vi.fn().mockResolvedValue({ isFile: () => true, size: source.byteLength }),
|
||||
read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => {
|
||||
const bytesRead = Math.min(Math.max(source.byteLength - position, 0), length)
|
||||
source.copy(buffer, offset, position, position + bytesRead)
|
||||
return { buffer, bytesRead }
|
||||
})
|
||||
})
|
||||
nativeImageCreateFromBufferMock.mockReturnValue({
|
||||
getSize: () => ({ height: 1, width: 1 }),
|
||||
isEmpty: () => false,
|
||||
toPNG: () => png
|
||||
})
|
||||
const writeFileBase64 = vi.fn().mockResolvedValue(undefined)
|
||||
getSshFilesystemProviderMock.mockReturnValue({
|
||||
getTempDir: vi.fn().mockResolvedValue('/var/tmp'),
|
||||
writeFileBase64
|
||||
})
|
||||
})
|
||||
nativeImageCreateFromBufferMock.mockReturnValue({
|
||||
getSize: () => ({ height: 1, width: 1 }),
|
||||
isEmpty: () => false,
|
||||
toPNG: () => png
|
||||
})
|
||||
const writeFileBase64 = vi.fn().mockResolvedValue(undefined)
|
||||
getSshFilesystemProviderMock.mockReturnValue({
|
||||
getTempDir: vi.fn().mockResolvedValue('/var/tmp'),
|
||||
writeFileBase64
|
||||
})
|
||||
|
||||
try {
|
||||
registerClipboardHandlers({} as never)
|
||||
try {
|
||||
registerClipboardHandlers({} as never)
|
||||
|
||||
const handler = getRegisteredHandlers().get('clipboard:saveImageAsTempFile')
|
||||
await expect(handler?.(makeClipboardEvent(), { connectionId: 'ssh-1' })).resolves.toBe(
|
||||
'/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png'
|
||||
)
|
||||
expect(clipboardReadBufferMock).toHaveBeenCalledWith('FileNameW')
|
||||
expect(clipboardReadBufferMock).toHaveBeenCalledWith('Shell IDList Array')
|
||||
expect(fsOpenMock).toHaveBeenCalledWith(sourcePath, 'r')
|
||||
expect(nativeImageCreateFromBufferMock).toHaveBeenCalledWith(source)
|
||||
expect(close).toHaveBeenCalled()
|
||||
expect(writeFileBase64).toHaveBeenCalledWith(
|
||||
'/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png',
|
||||
png.toString('base64')
|
||||
)
|
||||
expect(fsWriteFileMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
platformSpy.mockRestore()
|
||||
const probe = getRegisteredHandlers().get('clipboard:hasImage')
|
||||
expect(probe?.(makeClipboardEvent())).toBe(true)
|
||||
expect(() => probe?.(makeClipboardEvent({ id: 42 }))).toThrow()
|
||||
const handler = getRegisteredHandlers().get('clipboard:saveImageAsTempFile')
|
||||
await expect(handler?.(makeClipboardEvent(), { connectionId: 'ssh-1' })).resolves.toBe(
|
||||
'/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png'
|
||||
)
|
||||
expect(clipboardReadBufferMock).toHaveBeenCalledWith('FileNameW')
|
||||
expect(clipboardReadBufferMock).toHaveBeenCalledWith('Shell IDList Array')
|
||||
expect(fsOpenMock).toHaveBeenCalledWith(sourcePath, 'r')
|
||||
expect(nativeImageCreateFromBufferMock).toHaveBeenCalledWith(source)
|
||||
expect(close).toHaveBeenCalled()
|
||||
expect(writeFileBase64).toHaveBeenCalledWith(
|
||||
'/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png',
|
||||
png.toString('base64')
|
||||
)
|
||||
expect(fsWriteFileMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
platformSpy.mockRestore()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
it('saves clipboard images through the selected remote runtime host', async () => {
|
||||
const png = Buffer.alloc(512 * 1024)
|
||||
@@ -874,18 +885,4 @@ describe('registerClipboardHandlers', () => {
|
||||
expect(nativeImageCreateFromBufferMock).toHaveBeenCalled()
|
||||
expect(clipboardWriteImageMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[['text/plain'], false],
|
||||
[['text/plain', 'image/png'], true]
|
||||
])('reports image presence for %j from the format list without decoding', (formats, expected) => {
|
||||
setTrustedClipboardRendererWebContentsId(17)
|
||||
clipboardAvailableFormatsMock.mockReturnValue(formats)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registering handlers never reads the store for clipboard image presence.
|
||||
registerClipboardHandlers({} as never)
|
||||
const probe = getRegisteredHandlers().get('clipboard:hasImage')
|
||||
expect(probe?.(makeClipboardEvent())).toBe(expected)
|
||||
expect(clipboardReadImageMock).not.toHaveBeenCalled()
|
||||
expect(() => probe?.(makeClipboardEvent({ id: 42 }))).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
assertClipboardImageBase64LengthWithinLimit,
|
||||
assertClipboardImageByteLengthWithinLimit,
|
||||
assertClipboardImageDimensionsWithinLimit,
|
||||
clipboardFormatsIncludeImage,
|
||||
type ClipboardImageThumbnail
|
||||
} from '../../shared/clipboard-image'
|
||||
import {
|
||||
@@ -41,6 +40,7 @@ import {
|
||||
import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload'
|
||||
import { uploadPastedImageToAgentSessionAttachments } from '../ipc/agent-session-attachment-upload'
|
||||
import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-file'
|
||||
import { readClipboardImageSource } from './clipboard-image-source'
|
||||
import { readClipboardCopiedFilePaths } from './clipboard-copied-file-paths'
|
||||
import { buildClipboardImageThumbnail } from './clipboard-image-thumbnail'
|
||||
import { writeClipboardTextAndVerify } from './clipboard-text-write-verify'
|
||||
@@ -141,7 +141,7 @@ export function registerClipboardHandlers(store: Store): void {
|
||||
})
|
||||
ipcMain.handle('clipboard:hasImage', (event): boolean => {
|
||||
assertTrustedClipboardSender(event)
|
||||
return clipboardFormatsIncludeImage(clipboard.availableFormats())
|
||||
return readClipboardImageSource(clipboard) !== null
|
||||
})
|
||||
// Why: a file-manager copy also carries the files' names as text, which a paste must not type.
|
||||
ipcMain.handle('clipboard:readFilePaths', (event): string[] => {
|
||||
@@ -155,21 +155,19 @@ export function registerClipboardHandlers(store: Store): void {
|
||||
'clipboard:saveImageAsTempFile',
|
||||
async (event, args?: SaveClipboardImageAsTempFileArgs) => {
|
||||
assertTrustedClipboardSender(event)
|
||||
const source = readClipboardImageSource(clipboard)
|
||||
if (!source) {
|
||||
return null
|
||||
}
|
||||
const image = clipboard.readImage()
|
||||
if (image.isEmpty()) {
|
||||
if (process.platform !== 'win32') {
|
||||
if (!source.windowsFileFormats) {
|
||||
return null
|
||||
}
|
||||
const copiedFilePng = await readWindowsClipboardImageFileAsPng(
|
||||
{
|
||||
fileNameW: clipboard.readBuffer('FileNameW'),
|
||||
shellIdListArray: clipboard.readBuffer('Shell IDList Array')
|
||||
},
|
||||
{
|
||||
createImageFromBuffer: (buffer) => nativeImage.createFromBuffer(buffer),
|
||||
openFile: (filePath) => open(filePath, 'r')
|
||||
}
|
||||
)
|
||||
const copiedFilePng = await readWindowsClipboardImageFileAsPng(source.windowsFileFormats, {
|
||||
createImageFromBuffer: (buffer) => nativeImage.createFromBuffer(buffer),
|
||||
openFile: (filePath) => open(filePath, 'r')
|
||||
})
|
||||
return copiedFilePng ? saveClipboardImageBufferForTarget(copiedFilePng, args) : null
|
||||
}
|
||||
assertClipboardImageDimensionsWithinLimit(image.getSize())
|
||||
|
||||
@@ -15,13 +15,14 @@ const PNG = Buffer.from([0, 1, 2, 3])
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: vi.fn(() => '/tmp') },
|
||||
clipboard: {
|
||||
availableFormats: () => ['image/png'],
|
||||
readImage: () => ({
|
||||
getSize: () => ({ height: 1, width: 1 }),
|
||||
isEmpty: () => false,
|
||||
toPNG: () => PNG
|
||||
}),
|
||||
readText: vi.fn(),
|
||||
readBuffer: vi.fn(),
|
||||
readBuffer: vi.fn(() => Buffer.alloc(0)),
|
||||
writeText: vi.fn(),
|
||||
writeImage: vi.fn(),
|
||||
writeBuffer: vi.fn()
|
||||
|
||||
@@ -84,6 +84,15 @@ export function readWindowsCopiedFilePath({
|
||||
return hasAtMostOneShellItem(shellIdListArray) ? decodeFileNameW(fileNameW) : null
|
||||
}
|
||||
|
||||
export function readWindowsCopiedImageFilePath(
|
||||
formats: WindowsClipboardFileFormats
|
||||
): string | null {
|
||||
const filePath = readWindowsCopiedFilePath(formats)
|
||||
return filePath && IMAGE_FILE_EXTENSION_SET.has(win32.extname(filePath).toLowerCase())
|
||||
? filePath
|
||||
: null
|
||||
}
|
||||
|
||||
function readPngDimensions(source: Buffer): { height: number; width: number } | null {
|
||||
if (
|
||||
source.byteLength < 24 ||
|
||||
@@ -164,8 +173,8 @@ export async function readWindowsClipboardImageFileAsPng(
|
||||
formats: WindowsClipboardFileFormats,
|
||||
{ createImageFromBuffer, openFile }: WindowsClipboardImageFileDeps
|
||||
): Promise<Buffer | null> {
|
||||
const filePath = readWindowsCopiedFilePath(formats)
|
||||
if (!filePath || !IMAGE_FILE_EXTENSION_SET.has(win32.extname(filePath).toLowerCase())) {
|
||||
const filePath = readWindowsCopiedImageFilePath(formats)
|
||||
if (!filePath) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
+207
-22
@@ -4,23 +4,41 @@
|
||||
// replaces the composer. The real composer, attachment and paste hooks keep the upload, and Send
|
||||
// waits for it, whenever the composer comes back; a rich-text paste's image is owed from the start.
|
||||
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ClipboardEventLike } from './native-chat-clipboard-payload'
|
||||
import { createRef, type RefObject } from 'react'
|
||||
import type { NativeChatComposerHandle } from './native-chat-composer-types'
|
||||
import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload'
|
||||
import type {
|
||||
NativeChatComposerField as NativeChatComposerFieldComponent,
|
||||
NativeChatComposerFieldProps,
|
||||
NativeChatComposerImageAttachment
|
||||
} from './NativeChatComposerField'
|
||||
|
||||
type FieldProps = {
|
||||
onPaste?: (event: ClipboardEventLike) => void
|
||||
imageAttachments?: { path: string; pending?: boolean }[]
|
||||
imageAttachments?: readonly NativeChatComposerImageAttachment[]
|
||||
sendButtonDisabled?: boolean
|
||||
onRemoveImageAttachment: (id: string) => void
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state: { fieldProps: FieldProps | null } = { fieldProps: null }
|
||||
const state: { fieldProps: FieldProps | null; owner: NativeChatAttachmentOwner } = {
|
||||
fieldProps: null,
|
||||
owner: {
|
||||
kind: 'runtime-session',
|
||||
environmentId: 'env-1',
|
||||
pairingRevision: 1,
|
||||
sessionId: 'session-1'
|
||||
}
|
||||
}
|
||||
return { state, prepare: vi.fn(), save: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('../../store', () => {
|
||||
const state = {
|
||||
tabsByWorktree: {},
|
||||
worktrees: [],
|
||||
repos: [],
|
||||
dictationState: 'idle',
|
||||
settings: { voice: { enabled: false }, nativeChatSessionOptions: {} },
|
||||
agentStatusByPaneKey: {},
|
||||
@@ -43,24 +61,25 @@ vi.mock('@/lib/native-chat-telemetry', () => ({
|
||||
emitNativeChatPickerOpened: vi.fn(),
|
||||
emitNativeChatSendClassified: vi.fn()
|
||||
}))
|
||||
vi.mock('./NativeChatComposerField', () => ({
|
||||
NativeChatComposerField: (props: FieldProps) => {
|
||||
mocks.state.fieldProps = props
|
||||
return <div data-testid="native-chat-composer-field" />
|
||||
vi.mock('./NativeChatComposerField', async (importOriginal) => {
|
||||
const original = await importOriginal<{
|
||||
NativeChatComposerField: typeof NativeChatComposerFieldComponent
|
||||
}>()
|
||||
return {
|
||||
NativeChatComposerField: (props: NativeChatComposerFieldProps) => {
|
||||
mocks.state.fieldProps = props
|
||||
return <original.NativeChatComposerField {...props} />
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
vi.mock('./NativeChatComposerActions', () => ({ NativeChatComposerActions: () => null }))
|
||||
vi.mock('./use-native-chat-skills', () => ({
|
||||
useNativeChatSkills: () => ({ status: 'ready', skills: [], error: null, retry: () => {} })
|
||||
}))
|
||||
vi.mock('./use-native-chat-external-attachments', () => ({
|
||||
useNativeChatExternalAttachments: () => ({
|
||||
attachExternalPaths: vi.fn(),
|
||||
resolveAttachmentOwner: () => ({
|
||||
kind: 'runtime-session',
|
||||
environmentId: 'env-1',
|
||||
pairingRevision: 1,
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
resolveAttachmentOwner: () => mocks.state.owner
|
||||
})
|
||||
}))
|
||||
vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({
|
||||
@@ -71,6 +90,7 @@ vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({
|
||||
import { NativeChatComposer } from './NativeChatComposer'
|
||||
import {
|
||||
clearNativeChatDraftCacheForTests,
|
||||
readNativeChatDraftCache,
|
||||
writeNativeChatDraftCache
|
||||
} from './native-chat-draft-cache'
|
||||
import {
|
||||
@@ -81,9 +101,13 @@ import {
|
||||
const PANE = 'tab-1::session-1'
|
||||
const STORED = '/srv/agent-session-attachments/0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01/orca-paste.png'
|
||||
|
||||
function composer(): React.JSX.Element {
|
||||
function composer(
|
||||
acceptsImages = true,
|
||||
composerRef?: RefObject<NativeChatComposerHandle | null>
|
||||
): React.JSX.Element {
|
||||
return (
|
||||
<NativeChatComposer
|
||||
ref={composerRef}
|
||||
terminalTabId="tab-1"
|
||||
paneKey={PANE}
|
||||
targetPtyId={null}
|
||||
@@ -100,6 +124,7 @@ function composer(): React.JSX.Element {
|
||||
optionSnapshot: [],
|
||||
onError: vi.fn(),
|
||||
runtime: 'remote',
|
||||
acceptsImages,
|
||||
sessionId: 'session-1',
|
||||
runtimeEnvironmentId: 'env-1'
|
||||
}}
|
||||
@@ -123,7 +148,7 @@ function installPreloadApi(ui: Record<string, unknown>): void {
|
||||
})
|
||||
}
|
||||
|
||||
function imagePaste(text?: string): ClipboardEventLike {
|
||||
function imagePaste(text?: string): ClipboardEvent {
|
||||
const data = new DataTransfer()
|
||||
data.items.add(new File(['image'], 'image.png', { type: 'image/png' }))
|
||||
if (text) {
|
||||
@@ -147,6 +172,12 @@ beforeEach(() => {
|
||||
clearNativeChatAttachmentCacheForTests()
|
||||
clearNativeChatDraftCacheForTests()
|
||||
mocks.state.fieldProps = null
|
||||
mocks.state.owner = {
|
||||
kind: 'runtime-session',
|
||||
environmentId: 'env-1',
|
||||
pairingRevision: 1,
|
||||
sessionId: 'session-1'
|
||||
}
|
||||
mocks.prepare.mockResolvedValue({
|
||||
ok: true,
|
||||
target: {
|
||||
@@ -163,7 +194,9 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
@@ -177,7 +210,7 @@ describe('a paste into a chat on a paired server', () => {
|
||||
)
|
||||
installPreloadApi({ saveClipboardImageAsTempFile: mocks.save })
|
||||
const view = render(composer())
|
||||
await act(async () => mocks.state.fieldProps?.onPaste?.(imagePaste()))
|
||||
await act(async () => fireEvent(screen.getByRole('textbox'), imagePaste()))
|
||||
expect(mocks.state.fieldProps?.imageAttachments).toMatchObject([{ pending: true }])
|
||||
|
||||
// The agent asks a question: its card takes the composer's place while the image uploads.
|
||||
@@ -196,7 +229,7 @@ describe('a paste into a chat on a paired server', () => {
|
||||
// Something typed, so only the pending image can hold Send.
|
||||
writeNativeChatDraftCache(PANE, 'look at this')
|
||||
const first = render(composer())
|
||||
await act(async () => mocks.state.fieldProps?.onPaste?.(imagePaste()))
|
||||
await act(async () => fireEvent(screen.getByRole('textbox'), imagePaste()))
|
||||
first.unmount()
|
||||
|
||||
// Back from the prompt card while the image is still on its way to the server.
|
||||
@@ -217,7 +250,7 @@ describe('a paste into a chat on a paired server', () => {
|
||||
writeNativeChatDraftCache(PANE, 'look at this caption')
|
||||
render(composer())
|
||||
expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(false)
|
||||
await act(async () => mocks.state.fieldProps?.onPaste?.(imagePaste('caption')))
|
||||
await act(async () => fireEvent(screen.getByRole('textbox'), imagePaste('caption')))
|
||||
|
||||
// The text is in; the image is not yet anywhere, but it is owed to this message.
|
||||
expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(true)
|
||||
@@ -229,7 +262,7 @@ describe('a paste into a chat on a paired server', () => {
|
||||
const finishUpload = holdUpload()
|
||||
writeNativeChatDraftCache(PANE, 'look at this caption')
|
||||
const first = render(composer())
|
||||
await act(async () => mocks.state.fieldProps?.onPaste?.(imagePaste('caption')))
|
||||
await act(async () => fireEvent(screen.getByRole('textbox'), imagePaste('caption')))
|
||||
// A prompt card replaces the composer while the server is still being asked.
|
||||
first.unmount()
|
||||
render(composer())
|
||||
@@ -254,3 +287,155 @@ describe('a paste into a chat on a paired server', () => {
|
||||
expect(mocks.state.fieldProps?.imageAttachments).toMatchObject([{ path: STORED }])
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a local image paste after the real composer closes and reopens', async () => {
|
||||
mocks.state.owner = { kind: 'local' }
|
||||
mocks.save.mockClear()
|
||||
const finishUpload = holdUpload()
|
||||
const first = render(composer())
|
||||
await act(async () => fireEvent(screen.getByRole('textbox'), imagePaste()))
|
||||
await waitFor(() => expect(mocks.save).toHaveBeenCalled())
|
||||
expect(mocks.state.fieldProps?.imageAttachments).toMatchObject([{ pending: true }])
|
||||
first.unmount()
|
||||
await act(async () => finishUpload('/local/native-chat-pastes/orca-paste-1-image.png'))
|
||||
render(composer())
|
||||
expect(mocks.state.fieldProps?.imageAttachments).toMatchObject([
|
||||
{ path: '/local/native-chat-pastes/orca-paste-1-image.png' }
|
||||
])
|
||||
await waitFor(() => expect(mocks.state.fieldProps?.imageAttachments?.[0]?.pending).toBeFalsy())
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ kind: 'local', acceptsImages: false },
|
||||
{ kind: 'ssh', acceptsImages: false },
|
||||
{ kind: 'local', acceptsImages: true },
|
||||
{ kind: 'ssh', acceptsImages: true }
|
||||
] as const)(
|
||||
'can remove a stalled $kind paste after reopen with acceptsImages=$acceptsImages',
|
||||
async ({ kind, acceptsImages }) => {
|
||||
mocks.state.owner =
|
||||
kind === 'local'
|
||||
? { kind: 'local' }
|
||||
: {
|
||||
kind: 'ssh',
|
||||
connectionId: 'ssh-1',
|
||||
worktreePath: '/remote/folder',
|
||||
expectedExecutionHostId: 'ssh:ssh-1',
|
||||
expectedSshTargetId: 'ssh-1',
|
||||
expectedSshConnectionGeneration: 1
|
||||
}
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
|
||||
const finishUpload = holdUpload()
|
||||
writeNativeChatDraftCache(PANE, 'look at this')
|
||||
const first = render(composer(acceptsImages))
|
||||
expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(false)
|
||||
await act(async () => fireEvent(screen.getByRole('textbox'), imagePaste()))
|
||||
first.unmount()
|
||||
render(composer(acceptsImages))
|
||||
await act(async () => vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000))
|
||||
expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(true)
|
||||
expect(screen.getByRole('button', { name: 'Saving pasted image…' })).toBeTruthy()
|
||||
await act(async () =>
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove attachment' }))
|
||||
)
|
||||
expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(false)
|
||||
await act(async () =>
|
||||
finishUpload(
|
||||
kind === 'local'
|
||||
? '/local/native-chat-pastes/orca-paste-1-image.png'
|
||||
: '/tmp/orca-paste-1-image.png'
|
||||
)
|
||||
)
|
||||
expect(readNativeChatAttachmentCache(PANE)).toEqual([])
|
||||
expect(readNativeChatDraftCache(PANE)).toBe('look at this')
|
||||
expect(mocks.state.fieldProps?.imageAttachments).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
it.each([true, false])(
|
||||
'pastes ordinary menu text without image feedback or holding Send, acceptsImages=%s',
|
||||
async (acceptsImages) => {
|
||||
mocks.state.owner = { kind: 'local' }
|
||||
mocks.save.mockClear()
|
||||
mocks.save.mockReturnValue(new Promise(() => {}))
|
||||
installPreloadApi({
|
||||
saveClipboardImageAsTempFile: mocks.save,
|
||||
readClipboardText: async () => 'ordinary text',
|
||||
readClipboardFilePaths: async () => [],
|
||||
readClipboardImageThumbnail: async () => null,
|
||||
clipboardHasImage: async () => false
|
||||
})
|
||||
const handle = createRef<NativeChatComposerHandle>()
|
||||
writeNativeChatDraftCache(PANE, 'look at this')
|
||||
render(composer(acceptsImages, handle))
|
||||
await act(async () => handle.current?.pasteFromClipboard())
|
||||
expect(screen.getByRole('textbox').textContent).toContain('ordinary text')
|
||||
expect(screen.queryByRole('button', { name: 'Saving pasted image…' })).toBeNull()
|
||||
expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(false)
|
||||
expect(mocks.save).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([true, false])(
|
||||
'releases all previews when a paired paste is removed before acceptance, acceptsImages=%s',
|
||||
async (acceptsImages) => {
|
||||
const prepare = Promise.withResolvers<{
|
||||
ok: true
|
||||
target: {
|
||||
environmentId: string
|
||||
sessionId: string
|
||||
expectedEnvironmentPairingRevision: number
|
||||
}
|
||||
}>()
|
||||
mocks.prepare.mockReturnValue(prepare.promise)
|
||||
const finishUpload = holdUpload()
|
||||
const allocated = new Set<string>()
|
||||
const released = new Set<string>()
|
||||
vi.spyOn(URL, 'createObjectURL').mockImplementation(() => {
|
||||
const url = 'blob:late-acceptance'
|
||||
allocated.add(url)
|
||||
return url
|
||||
})
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation((url) => {
|
||||
released.add(url)
|
||||
})
|
||||
const view = render(composer(acceptsImages))
|
||||
await act(async () => fireEvent(screen.getByRole('textbox'), imagePaste('caption')))
|
||||
await act(async () =>
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove attachment' }))
|
||||
)
|
||||
await act(async () =>
|
||||
prepare.resolve({
|
||||
ok: true,
|
||||
target: {
|
||||
environmentId: 'env-1',
|
||||
sessionId: 'session-1',
|
||||
expectedEnvironmentPairingRevision: 1
|
||||
}
|
||||
})
|
||||
)
|
||||
await act(async () => finishUpload(STORED))
|
||||
expect(readNativeChatAttachmentCache(PANE)).toEqual([])
|
||||
view.unmount()
|
||||
expect([...allocated].filter((url) => !released.has(url))).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
it('starts no upload when the composer closes before image presence is known', async () => {
|
||||
mocks.state.owner = { kind: 'local' }
|
||||
mocks.save.mockClear()
|
||||
const presence = Promise.withResolvers<boolean>()
|
||||
installPreloadApi({
|
||||
saveClipboardImageAsTempFile: mocks.save,
|
||||
readClipboardText: async () => '',
|
||||
readClipboardFilePaths: async () => [],
|
||||
clipboardHasImage: () => presence.promise
|
||||
})
|
||||
const handle = createRef<NativeChatComposerHandle>()
|
||||
const view = render(composer(true, handle))
|
||||
await act(async () => handle.current?.pasteFromClipboard())
|
||||
view.unmount()
|
||||
await act(async () => presence.resolve(true))
|
||||
expect(mocks.save).not.toHaveBeenCalled()
|
||||
expect(readNativeChatAttachmentCache(PANE)).toEqual([])
|
||||
})
|
||||
|
||||
@@ -197,6 +197,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
})
|
||||
|
||||
const handlePasteEvent = useNativeChatComposerHandle(ref, {
|
||||
attachmentScopeKey: draftScopeKey,
|
||||
textareaRef,
|
||||
caret,
|
||||
draft,
|
||||
|
||||
@@ -100,8 +100,6 @@ export type NativeChatComposerImageAttachment = {
|
||||
pending?: boolean
|
||||
/** The file's name while it uploads: a dropped or picked file, not a pasted image. */
|
||||
pendingName?: string
|
||||
/** Owed to the message but not shown yet: a rich-text paste's image while its server is asked. */
|
||||
hidden?: true
|
||||
/** Set on an image the draft names but can't send: the file to attach again. */
|
||||
unavailableName?: string
|
||||
}
|
||||
@@ -174,7 +172,6 @@ export function NativeChatComposerField({
|
||||
sessionOptionsPickerRequest,
|
||||
goalMode
|
||||
}: NativeChatComposerFieldProps): React.JSX.Element {
|
||||
const shownAttachments = imageAttachments.filter((attachment) => !attachment.hidden)
|
||||
const draftNotSaved = useNativeChatComposerDraftUnsaved(draftScopeKey)
|
||||
const optionCount =
|
||||
autocomplete.mode === 'slash'
|
||||
@@ -282,9 +279,9 @@ export function NativeChatComposerField({
|
||||
'[contain:paint]'
|
||||
)}
|
||||
>
|
||||
{shownAttachments.length > 0 ? (
|
||||
{imageAttachments.length > 0 ? (
|
||||
<div className="mb-2 flex flex-wrap gap-2 px-1 pt-1.5">
|
||||
{shownAttachments.map((attachment) => (
|
||||
{imageAttachments.map((attachment) => (
|
||||
<NativeChatImageAttachmentPreview
|
||||
key={attachment.id}
|
||||
attachment={attachment}
|
||||
|
||||
@@ -115,4 +115,45 @@ describe('native-chat composer draft addition', () => {
|
||||
expect(next.drafts.readNativeChatDraftCache('agent-session:s2')).toBe('please go\n\ngo')
|
||||
expect(storage.drafts.get('agent-session:s2')?.text).toBe('please go\n\ngo')
|
||||
})
|
||||
|
||||
it.each(['delayed load', 'crash replay'])(
|
||||
'keeps picked nodes when appending through %s',
|
||||
async (mode) => {
|
||||
const document = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'nativeChatSkill', attrs: { token: '$review' } },
|
||||
{ type: 'text', text: '-long' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
storage.drafts.set(SCOPE, { text: '$review-long', document, images: [], savedAt: 1 })
|
||||
let next: DraftModules
|
||||
if (mode === 'crash replay') {
|
||||
const crashed = await reload({
|
||||
using: { ...storage, write: () => new Promise<void>(() => {}) }
|
||||
})
|
||||
crashed.drafts.appendNativeChatDraftCache(SCOPE, '@/stored/file.pdf')
|
||||
crashed.store.clearNativeChatComposerDraftsForTests()
|
||||
next = await reload()
|
||||
} else {
|
||||
const { using, land } = slowLoading()
|
||||
next = await reload({ using, hydrate: false })
|
||||
await next.store.waitForNativeChatComposerDrafts(1)
|
||||
next.drafts.appendNativeChatDraftCache(SCOPE, '@/stored/file.pdf')
|
||||
land()
|
||||
await next.store.waitForNativeChatComposerDrafts(1_000)
|
||||
}
|
||||
await next.store.nativeChatComposerDraftWritesSettled()
|
||||
expect(next.drafts.readNativeChatDraftCache(SCOPE)).toBe('$review-long\n\n@/stored/file.pdf')
|
||||
expect(next.store.readNativeChatComposerDraft(SCOPE).document?.content?.[0]).toEqual(
|
||||
document.content[0]
|
||||
)
|
||||
expect(storage.drafts.get(SCOPE)?.document?.content?.[0]).toEqual(document.content[0])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// loaded later, or replayed from the journal after a crash.
|
||||
|
||||
import { appendReturnedDraftText } from '../../../../shared/returned-draft-text'
|
||||
import { appendPromptDocumentText } from './native-chat-prompt-document-text'
|
||||
import { basename } from '@/lib/path'
|
||||
import type {
|
||||
NativeChatComposerDraft,
|
||||
@@ -25,7 +26,7 @@ export function withNativeChatComposerDraftAddition(
|
||||
draft: NativeChatComposerDraft,
|
||||
addition: NativeChatComposerDraftAddition,
|
||||
options: { once?: boolean } = {}
|
||||
): Pick<NativeChatComposerDraft, 'text' | 'images'> {
|
||||
): Pick<NativeChatComposerDraft, 'text' | 'images' | 'document'> {
|
||||
const text = appendReturnedDraftText(draft.text, addition.text ?? '')
|
||||
const images = [...draft.images]
|
||||
for (const { id, path, connectionId } of addition.images ?? []) {
|
||||
@@ -42,5 +43,5 @@ export function withNativeChatComposerDraftAddition(
|
||||
images[placeholder] = image
|
||||
}
|
||||
}
|
||||
return { text, images }
|
||||
return { text, images, document: appendPromptDocumentText(draft.document, draft.text, text) }
|
||||
}
|
||||
|
||||
@@ -145,7 +145,6 @@ export function replayNativeChatComposerDraftJournal(
|
||||
drafts.set(scopeKey, {
|
||||
...from,
|
||||
...next,
|
||||
...(next.text === from.text ? {} : { document: undefined }),
|
||||
savedAt: at
|
||||
})
|
||||
changed.add(scopeKey)
|
||||
|
||||
@@ -189,7 +189,7 @@ function withAddition<T extends NativeChatComposerDraft>(
|
||||
options: { once?: boolean } = {}
|
||||
): T {
|
||||
const next = withNativeChatComposerDraftAddition(draft, addition, options)
|
||||
return { ...draft, ...next, ...(next.text === draft.text ? {} : { document: undefined }) }
|
||||
return { ...draft, ...next }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,13 +206,13 @@ export function appendToNativeChatComposerDraft(
|
||||
): boolean {
|
||||
const before = records.get(scopeKey)
|
||||
const current = readNativeChatComposerDraft(scopeKey)
|
||||
const { text, images } = withAddition(current, addition)
|
||||
const { text, images, document } = withAddition(current, addition)
|
||||
updateNativeChatComposerDraft(
|
||||
scopeKey,
|
||||
{
|
||||
text,
|
||||
images,
|
||||
...(text === current.text ? {} : { document: undefined }),
|
||||
document,
|
||||
...(owner ? { owner } : {})
|
||||
},
|
||||
'immediate',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload'
|
||||
|
||||
export type UseNativeChatComposerPasteArgs = {
|
||||
targetKey?: string
|
||||
attachmentScopeKey?: string
|
||||
agent: AgentType
|
||||
/** Re-read after awaits so a mid-paste disable guards the composer. */
|
||||
disabled: boolean
|
||||
caret: number
|
||||
/** Resolved at paste time: SSH panes must save the clipboard image on the
|
||||
* remote host, or the attached path names a file the agent cannot read. */
|
||||
resolveAttachmentOwner: () => NativeChatAttachmentOwner
|
||||
attachResolvedPaths: (paths: string[], connectionId?: string | null) => void
|
||||
beginPendingImageAttachment: (previewUrl?: string, pendingName?: string) => string | null
|
||||
resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void
|
||||
/** Supplies a pending chip's thumbnail. */
|
||||
revealPendingImageAttachment?: (id: string, previewUrl?: string) => void
|
||||
dropPendingImageAttachment: (id: string) => void
|
||||
insertTypedText: (text: string) => boolean
|
||||
setCaret: (caret: number) => void
|
||||
setNotice: (notice: string | null, errorText?: string) => void
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
// @vitest-environment happy-dom
|
||||
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 { NativeChatPromptEditor } from './NativeChatPromptEditor'
|
||||
import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
import {
|
||||
clearNativeChatComposerDraftsForTests,
|
||||
hydrateNativeChatComposerDrafts,
|
||||
nativeChatComposerDraftWritesSettled,
|
||||
readNativeChatComposerDraft,
|
||||
structuredAgentSessionDraftScopeKey as scope,
|
||||
updateNativeChatComposerDraft
|
||||
} from './native-chat-composer-draft-store'
|
||||
import {
|
||||
createMemoryNativeChatComposerDraftStorage,
|
||||
setNativeChatComposerDraftStorageForTests
|
||||
} from './native-chat-composer-draft-storage'
|
||||
import {
|
||||
clearNativeChatPendingAttachmentsForTests,
|
||||
nativeChatPendingAttachmentSnapshot,
|
||||
takeNativeChatPendingAttachment
|
||||
} from './native-chat-pending-attachment-cache'
|
||||
|
||||
// Only host replies are controlled; pending-chip ownership and upload completion are production code.
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
callRuntimeRpc: vi.fn(async () => ({
|
||||
runtimeId: 'paired-host',
|
||||
capabilities: ['agent-session.attachments.v1']
|
||||
}))
|
||||
}))
|
||||
let previousApi: typeof window.api
|
||||
beforeEach(async () => {
|
||||
previousApi = window.api
|
||||
setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage())
|
||||
await hydrateNativeChatComposerDrafts()
|
||||
})
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
await nativeChatComposerDraftWritesSettled()
|
||||
clearNativeChatPendingAttachmentsForTests()
|
||||
clearNativeChatComposerDraftsForTests()
|
||||
window.api = previousApi
|
||||
})
|
||||
async function uploadFixture(
|
||||
extension: string,
|
||||
{ removed = false, withSkill = false, switchScope = false } = {}
|
||||
) {
|
||||
const path = `/srv/agent-session-attachments/u1/report.${extension}`
|
||||
const sourcePath = `/local/report.${extension}`
|
||||
let finish = () => {}
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
fs: {
|
||||
uploadPathsToAgentSessionAttachments: () =>
|
||||
new Promise((resolve) => {
|
||||
finish = () => resolve({ uploaded: [{ sourcePath, path }], skipped: [], failed: [] })
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
updateNativeChatComposerDraft(scope('a'), { text: 'next question' }, 'immediate')
|
||||
if (withSkill) {
|
||||
const inputRef = createRef<NativeChatComposerInput>()
|
||||
const picker = render(
|
||||
<NativeChatPromptEditor
|
||||
scopeKey={scope('a')}
|
||||
inputRef={inputRef}
|
||||
initialValue="$rev"
|
||||
disabled={false}
|
||||
placeholder="Message"
|
||||
onChange={(input) =>
|
||||
updateNativeChatComposerDraft(scope('a'), { text: input.value }, 'immediate')
|
||||
}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
)
|
||||
act(() => {
|
||||
inputRef.current?.insertSkill?.(0, 4, '$review')
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '$review-long'
|
||||
}
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(picker.container.querySelector('[data-native-chat-skill]')).not.toBeNull()
|
||||
)
|
||||
picker.unmount()
|
||||
}
|
||||
const textareaRef = createRef<NativeChatComposerInput>()
|
||||
const view = renderHook(
|
||||
({ draftScope }) => {
|
||||
return useNativeChatComposerAttachments({
|
||||
attachmentScopeKey: draftScope,
|
||||
allowWithoutTarget: true,
|
||||
caret: 0,
|
||||
disabled: false,
|
||||
isComposing: () => false,
|
||||
resolveTarget: () => null,
|
||||
textareaRef,
|
||||
setCaret: () => {},
|
||||
setDraft: (updater) =>
|
||||
updateNativeChatComposerDraft(
|
||||
draftScope,
|
||||
{ text: updater(readNativeChatComposerDraft(draftScope).text) },
|
||||
'immediate'
|
||||
),
|
||||
setNotice: () => {}
|
||||
})
|
||||
},
|
||||
{ initialProps: { draftScope: scope('a') } }
|
||||
)
|
||||
let uploaded = Promise.resolve()
|
||||
act(() => {
|
||||
uploaded = attachNativeChatSessionAttachmentPaths({
|
||||
paths: [sourcePath],
|
||||
owner: {
|
||||
kind: 'runtime-session',
|
||||
environmentId: 'paired',
|
||||
sessionId: 'a',
|
||||
pairingRevision: 1
|
||||
},
|
||||
chips: view.result.current.pendingChips,
|
||||
isAbandoned: () => false,
|
||||
ownerStillCurrent: () => true,
|
||||
setNotice: () => {}
|
||||
})
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const chip = nativeChatPendingAttachmentSnapshot(scope('a'))[0]
|
||||
if (!chip) {
|
||||
throw new Error('Expected pending upload chip')
|
||||
}
|
||||
expect(chip.pendingName).toBe(`report.${extension}`)
|
||||
const targetScope = scope('a')
|
||||
if (switchScope) {
|
||||
act(() => view.rerender({ draftScope: scope('b') }))
|
||||
render(
|
||||
<NativeChatPromptEditor
|
||||
scopeKey={scope('b')}
|
||||
inputRef={textareaRef}
|
||||
initialValue="other question"
|
||||
disabled={false}
|
||||
placeholder="Message"
|
||||
onChange={(input) =>
|
||||
updateNativeChatComposerDraft(scope('b'), { text: input.value }, 'immediate')
|
||||
}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (removed) {
|
||||
act(() => takeNativeChatPendingAttachment(targetScope, chip.id))
|
||||
}
|
||||
if (!switchScope) {
|
||||
view.unmount()
|
||||
}
|
||||
finish()
|
||||
await uploaded
|
||||
return {
|
||||
path,
|
||||
targetScope,
|
||||
other: readNativeChatComposerDraft(scope('b')),
|
||||
otherInput: textareaRef.current?.value,
|
||||
target: readNativeChatComposerDraft(targetScope),
|
||||
pending: nativeChatPendingAttachmentSnapshot(targetScope)
|
||||
}
|
||||
}
|
||||
it.each(['pdf', 'png'])(
|
||||
'settles an unmounted paired %s upload in its owning draft',
|
||||
async (extension) => {
|
||||
const result = await uploadFixture(extension)
|
||||
expect(result.pending).toEqual([])
|
||||
if (extension === 'pdf') {
|
||||
expect(result.target.text).toContain(`@${result.path}`)
|
||||
} else {
|
||||
expect(result.target.images[0]?.path).toBe(result.path)
|
||||
}
|
||||
expect(result.other.text).toBe('')
|
||||
expect(result.other.images).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
it('does not attach a file reference removed while uploading', async () => {
|
||||
const result = await uploadFixture('pdf', { removed: true })
|
||||
expect(result.pending).toEqual([])
|
||||
expect(result.target.text).toBe('next question')
|
||||
})
|
||||
|
||||
it('keeps the original file owner when the mounted composer switches draft scopes', async () => {
|
||||
const result = await uploadFixture('pdf', { switchScope: true })
|
||||
expect(result.pending).toEqual([])
|
||||
expect(result.target.text).toBe(`next question\n\n@${result.path}`)
|
||||
expect(result.otherInput).toBe('other question')
|
||||
expect(result.other.images).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['pdf', 'png'])(
|
||||
'preserves a picked skill and its literal suffix after an unmounted %s upload',
|
||||
async (extension) => {
|
||||
const result = await uploadFixture(extension, { withSkill: true })
|
||||
expect(result.pending).toEqual([])
|
||||
const inputRef = createRef<NativeChatComposerInput>()
|
||||
const view = render(
|
||||
<NativeChatPromptEditor
|
||||
scopeKey={result.targetScope}
|
||||
inputRef={inputRef}
|
||||
initialValue={result.target.text}
|
||||
disabled={false}
|
||||
placeholder="Message"
|
||||
onChange={() => {}}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
)
|
||||
expect(inputRef.current?.value).toContain('$review-long')
|
||||
if (extension === 'pdf') {
|
||||
expect(inputRef.current?.value).toContain(`@${result.path}`)
|
||||
} else {
|
||||
expect(result.target.images[0]?.path).toBe(result.path)
|
||||
}
|
||||
await vi.waitFor(() => {
|
||||
expect(view.container.querySelectorAll('[data-native-chat-skill]')).toHaveLength(1)
|
||||
expect(view.container.querySelector('[data-native-chat-skill]')?.textContent).toBe('Review')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
|
||||
import type { JSONContent } from '@tiptap/react'
|
||||
import type { NativeChatComposerDraftOwner } from './native-chat-composer-draft-storage'
|
||||
// The composer's in-progress draft text and its editor document, keyed by the same stable pane
|
||||
// scope as image attachments. The composer unmounts when the pane toggles back to the hosted
|
||||
// terminal, so without this the typed-but-unsent draft would be lost on every TUI/GUI round-trip.
|
||||
@@ -40,13 +41,17 @@ const appendListeners = new Map<string, Set<(text: string, previous: string) =>
|
||||
|
||||
/** Puts text back after whatever is typed, and tells a mounted composer to show it. True once it
|
||||
* is durable, so the copy it came from may go. */
|
||||
export function appendNativeChatDraftCache(scopeKey: string, text: string): boolean {
|
||||
export function appendNativeChatDraftCache(
|
||||
scopeKey: string,
|
||||
text: string,
|
||||
owner?: NativeChatComposerDraftOwner
|
||||
): boolean {
|
||||
if (text === '') {
|
||||
return true
|
||||
}
|
||||
const previous = readNativeChatDraftCache(scopeKey)
|
||||
// Durable now: the copy it came from (a send handed back, a queued card) goes right after this.
|
||||
const durable = appendToNativeChatComposerDraft(scopeKey, { text })
|
||||
const durable = appendToNativeChatComposerDraft(scopeKey, { text }, owner)
|
||||
appendListeners.get(scopeKey)?.forEach((listener) => listener(text, previous))
|
||||
return durable
|
||||
}
|
||||
|
||||
+21
-19
@@ -1,13 +1,12 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
addNativeChatPendingAttachment,
|
||||
clearNativeChatPendingAttachmentsForTests,
|
||||
dropNativeChatPendingAttachmentsForTab,
|
||||
dropNativeChatPendingAttachmentsOwnedBy,
|
||||
nativeChatPendingAttachmentSnapshot,
|
||||
revealNativeChatPendingAttachment,
|
||||
settleNativeChatPendingAttachment,
|
||||
subscribeToNativeChatPendingAttachments,
|
||||
settleNativeChatPendingAttachmentReferences,
|
||||
takeNativeChatPendingAttachment
|
||||
} from './native-chat-pending-attachment-cache'
|
||||
import {
|
||||
@@ -58,22 +57,6 @@ describe('the pane pending attachment cache', () => {
|
||||
expect(takeNativeChatPendingAttachment('pane-b', 'b1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows a hidden chip and tells every subscriber, with a new snapshot only on change', () => {
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = subscribeToNativeChatPendingAttachments('pane-c', listener)
|
||||
addNativeChatPendingAttachment('pane-c', { id: 'c1', path: '', pending: true, hidden: true })
|
||||
const hidden = nativeChatPendingAttachmentSnapshot('pane-c')
|
||||
|
||||
expect(nativeChatPendingAttachmentSnapshot('pane-c')).toBe(hidden)
|
||||
revealNativeChatPendingAttachment('pane-c', 'c1')
|
||||
|
||||
expect(nativeChatPendingAttachmentSnapshot('pane-c')).toEqual([
|
||||
{ id: 'c1', path: '', pending: true }
|
||||
])
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('settles into a draft owned by the workspace it began in, after its chat stopped naming one', () => {
|
||||
const conversation = structuredAgentSessionDraftScopeKey('session-1')
|
||||
let open = true
|
||||
@@ -89,6 +72,25 @@ describe('the pane pending attachment cache', () => {
|
||||
expect(readNativeChatComposerDraft(conversation).images).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps reference settlement owned by the workspace after its chat closes', () => {
|
||||
const conversation = structuredAgentSessionDraftScopeKey('session-1')
|
||||
let open = true
|
||||
setNativeChatComposerDraftOwnerResolver((scopeKey) =>
|
||||
open && scopeKey === conversation ? OWNER : undefined
|
||||
)
|
||||
addPending(conversation, 'file')
|
||||
open = false
|
||||
|
||||
settleNativeChatPendingAttachmentReferences(conversation, [
|
||||
{ id: 'file', path: '/store/a.pdf' }
|
||||
])
|
||||
|
||||
expect(readNativeChatComposerDraft(conversation).text).toBe('@/store/a.pdf')
|
||||
expect(nativeChatPendingAttachmentSnapshot(conversation)).toEqual([])
|
||||
deleteNativeChatComposerDraftsOwnedBy(OWNER)
|
||||
expect(readNativeChatComposerDraft(conversation).text).toBe('')
|
||||
})
|
||||
|
||||
it('drops the chips begun in a removed workspace on that host only', () => {
|
||||
const resolved = new Map<string, NativeChatComposerDraftOwner>([
|
||||
['tab-1:leaf', OWNER],
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
// A composer's chips still on their way (a save, an upload, a server's answer), per pane scope,
|
||||
// owned here rather than by the composer. A prompt card unmounts the composer, so anything owed to
|
||||
// the message must outlive it: the composer that comes back shows the same chips, Send waits for
|
||||
// them, and each settles into the scope's draft whichever composer, if any, is showing. They are
|
||||
// never saved with the draft, so a restored draft cannot bring back an upload as if it were
|
||||
// attached. Every chip dies with its operation: settled or dropped when the save or upload settles
|
||||
// (each bounded by its call timeout), removed by the user, or dropped with the drafts of a tab or
|
||||
// workspace the user closed or removed, so none settles into a draft nothing could find or delete.
|
||||
// Pending operations belong to draft scopes and outlive their composers. They are not persisted.
|
||||
// Completion, failure, explicit removal, draft/workspace deletion or renderer exit ends ownership.
|
||||
|
||||
import { useCallback, useSyncExternalStore } from 'react'
|
||||
import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
|
||||
import type { NativeChatComposerDraftOwner } from './native-chat-composer-draft-storage'
|
||||
import { appendNativeChatDraftCache } from './native-chat-draft-cache'
|
||||
import { formatNativeChatFileReference } from '../../../../shared/agent-image-paste'
|
||||
import {
|
||||
appendToNativeChatComposerDraft,
|
||||
nativeChatDraftScopeTabId,
|
||||
@@ -115,17 +111,23 @@ export function settleNativeChatPendingAttachment(
|
||||
return true
|
||||
}
|
||||
|
||||
/** Shows a pending chip that was held out of sight, such as while a server was asked first. */
|
||||
export function revealNativeChatPendingAttachment(scopeKey: string, id: string): void {
|
||||
const current = nativeChatPendingAttachmentSnapshot(scopeKey)
|
||||
if (current.some((attachment) => attachment.id === id && attachment.hidden)) {
|
||||
writePending(
|
||||
scopeKey,
|
||||
current.map((attachment) => {
|
||||
const { hidden: _hidden, ...shown } = attachment
|
||||
return attachment.id === id ? shown : attachment
|
||||
})
|
||||
)
|
||||
/** File results settle only while their operation still belongs to this draft. */
|
||||
export function settleNativeChatPendingAttachmentReferences(
|
||||
scopeKey: string,
|
||||
references: { id: string; path: string }[],
|
||||
insertAtCaret?: (paths: string[]) => void
|
||||
): void {
|
||||
const owner = pendingOwners.get(scopeKey)
|
||||
const paths = references.flatMap(({ id, path }) =>
|
||||
takeNativeChatPendingAttachment(scopeKey, id) ? [path] : []
|
||||
)
|
||||
if (paths.length === 0) {
|
||||
return
|
||||
}
|
||||
if (insertAtCaret) {
|
||||
insertAtCaret(paths)
|
||||
} else {
|
||||
appendNativeChatDraftCache(scopeKey, paths.map(formatNativeChatFileReference).join(' '), owner)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { withNativeChatComposerDraftAddition } from './native-chat-composer-draft-addition'
|
||||
import type { NativeChatComposerDraft } from './native-chat-composer-draft-storage'
|
||||
|
||||
it.each(['spaces', 'hard breaks', 'empty paragraphs'])(
|
||||
'trims trailing %s while keeping picked skills and earlier paragraphs',
|
||||
(trailing) => {
|
||||
const first = { type: 'paragraph', content: [{ type: 'text', text: 'first' }] }
|
||||
const picked = { type: 'nativeChatSkill', attrs: { token: '$review' } }
|
||||
const draft: NativeChatComposerDraft = {
|
||||
text: `first\n$review${trailing === 'spaces' ? ' ' : '\n\n'}`,
|
||||
images: [],
|
||||
document: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
first,
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
picked,
|
||||
...(trailing === 'spaces'
|
||||
? [{ type: 'text', text: ' ' }]
|
||||
: trailing === 'hard breaks'
|
||||
? [{ type: 'hardBreak' }, { type: 'hardBreak' }]
|
||||
: [])
|
||||
]
|
||||
},
|
||||
...(trailing === 'empty paragraphs'
|
||||
? [
|
||||
{ type: 'paragraph', content: [] },
|
||||
{ type: 'paragraph', content: [] }
|
||||
]
|
||||
: [])
|
||||
]
|
||||
}
|
||||
}
|
||||
const result = withNativeChatComposerDraftAddition(draft, { text: 'go' })
|
||||
expect(result.text).toBe('first\n$review\n\ngo')
|
||||
expect(result.document?.content).toEqual([
|
||||
first,
|
||||
{ type: 'paragraph', content: [picked] },
|
||||
{ type: 'paragraph', content: [] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'go' }] }
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it('replaces an all-whitespace document with literal returned text', () => {
|
||||
const result = withNativeChatComposerDraftAddition(
|
||||
{
|
||||
text: ' \n',
|
||||
images: [],
|
||||
document: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: ' ' }] },
|
||||
{ type: 'paragraph', content: [] }
|
||||
]
|
||||
}
|
||||
},
|
||||
{ text: '$review' }
|
||||
)
|
||||
expect(result.document?.content).toEqual([
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '$review' }] }
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { JSONContent } from '@tiptap/react'
|
||||
|
||||
export function promptTextContent(text: string): JSONContent {
|
||||
return {
|
||||
type: 'doc',
|
||||
content: text.split('\n').map((line) => ({
|
||||
type: 'paragraph',
|
||||
content: line ? [{ type: 'text', text: line }] : []
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies an appended text change without replacing existing picked nodes. */
|
||||
export function appendPromptDocumentText(
|
||||
document: JSONContent | undefined,
|
||||
before: string,
|
||||
after: string
|
||||
): JSONContent | undefined {
|
||||
if (!document || before === after) {
|
||||
return document
|
||||
}
|
||||
const kept = before.trimEnd()
|
||||
if (kept === '') {
|
||||
return promptTextContent(after)
|
||||
}
|
||||
const blocks = [...(document.content ?? [])]
|
||||
while (blocks.length > 0) {
|
||||
const block = blocks.pop()
|
||||
const content = [...(block?.content ?? [])]
|
||||
while (content.length > 0) {
|
||||
const node = content.at(-1)
|
||||
if (node?.type === 'hardBreak') {
|
||||
content.pop()
|
||||
} else if (node?.type === 'text') {
|
||||
const text = node.text?.trimEnd() ?? ''
|
||||
if (text === '') {
|
||||
content.pop()
|
||||
} else {
|
||||
content[content.length - 1] = { ...node, text }
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (content.length > 0 && block) {
|
||||
const [first, ...rest] = promptTextContent(after.slice(kept.length)).content ?? []
|
||||
return {
|
||||
...document,
|
||||
content: [
|
||||
...blocks,
|
||||
{ ...block, content: [...content, ...(first?.content ?? [])] },
|
||||
...rest
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
return promptTextContent(after)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NativeChatSkillPill } from './NativeChatSkillPill'
|
||||
import { ReactNodeViewRenderer, Node, type JSONContent } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, Node } from '@tiptap/react'
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
export { promptTextContent } from './native-chat-prompt-document-text'
|
||||
|
||||
export const NativeChatSkill = Node.create({
|
||||
name: 'nativeChatSkill',
|
||||
@@ -24,16 +25,6 @@ export const NativeChatSkill = Node.create({
|
||||
]
|
||||
})
|
||||
|
||||
export function promptTextContent(text: string): JSONContent {
|
||||
return {
|
||||
type: 'doc',
|
||||
content: text.split('\n').map((line) => ({
|
||||
type: 'paragraph',
|
||||
content: line ? [{ type: 'text', text: line }] : []
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/** Each boundary maps a plain-text caret to a document position, including atomic skills. */
|
||||
export function promptTextMap(doc: ProseMirrorNode): { text: string; positions: number[] } {
|
||||
let text = ''
|
||||
|
||||
@@ -18,7 +18,7 @@ export type NativeChatPendingAttachmentChips = {
|
||||
drop: (id: string) => boolean
|
||||
/** Adds stored files as `@path` references to the scope's draft, which keeps them through an
|
||||
* input-method composition or a remount. */
|
||||
attachReferences: (paths: string[]) => void
|
||||
attachReferences: (references: { id: string; path: string }[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,7 +86,7 @@ export async function attachNativeChatSessionAttachmentPaths(args: {
|
||||
return
|
||||
}
|
||||
const notAttached: { name: string; reason: string }[] = []
|
||||
const references: string[] = []
|
||||
const references: { id: string; path: string }[] = []
|
||||
for (const { path, chipId } of pending) {
|
||||
const storedPath = stored.get(path)
|
||||
if (!storedPath) {
|
||||
@@ -100,9 +100,7 @@ export async function attachNativeChatSessionAttachmentPaths(args: {
|
||||
continue
|
||||
}
|
||||
// Other files become `@path` references, as every attach does, unless their chip was removed.
|
||||
if (args.chips.drop(chipId)) {
|
||||
references.push(storedPath)
|
||||
}
|
||||
references.push({ id: chipId, path: storedPath })
|
||||
}
|
||||
// Inserting at the caret clears the notice, so what failed is said after.
|
||||
if (references.length > 0) {
|
||||
|
||||
+31
-9
@@ -477,13 +477,14 @@ describe('useNativeChatComposerAttachments', () => {
|
||||
it('keeps an upload that finishes while the composer is unmounted, for when it returns', async () => {
|
||||
const probe = await renderProbe('pty-gone')
|
||||
const chips = probe.latest().pendingChips
|
||||
const begun: { image?: string | null; removed?: string | null } = {}
|
||||
const begun: { image?: string | null; removed?: string | null; reference?: string | null } = {}
|
||||
act(() => {
|
||||
begun.image = chips.begin(undefined, 'shot.png')
|
||||
begun.removed = chips.begin(undefined, 'old.png')
|
||||
begun.reference = chips.begin(undefined, 'notes.pdf')
|
||||
})
|
||||
const { image, removed } = begun
|
||||
if (!image || !removed) {
|
||||
const { image, removed, reference } = begun
|
||||
if (!image || !removed || !reference) {
|
||||
throw new Error('expected pending chips')
|
||||
}
|
||||
act(() => probe.latest().removeImageAttachment(removed))
|
||||
@@ -492,7 +493,7 @@ describe('useNativeChatComposerAttachments', () => {
|
||||
|
||||
chips.resolve(image, '/srv/agent-session-attachments/u1/shot.png')
|
||||
chips.resolve(removed, '/srv/agent-session-attachments/u2/old.png')
|
||||
chips.attachReferences(['/srv/agent-session-attachments/u3/notes.pdf'])
|
||||
chips.attachReferences([{ id: reference, path: '/srv/agent-session-attachments/u3/notes.pdf' }])
|
||||
|
||||
expect(readNativeChatAttachmentCache('pty-gone')).toEqual([
|
||||
{ id: image, path: '/srv/agent-session-attachments/u1/shot.png' }
|
||||
@@ -531,11 +532,33 @@ describe('useNativeChatComposerAttachments', () => {
|
||||
act(() => back.root.unmount())
|
||||
})
|
||||
|
||||
it('gives uploads unique IDs across composers remounted in the same clock tick', async () => {
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000)
|
||||
try {
|
||||
const first = await renderProbe('pty-ids', true)
|
||||
const ids: (string | null)[] = []
|
||||
act(() => ids.push(first.latest().pendingChips.begin(undefined, 'first.pdf')))
|
||||
act(() => first.root.unmount())
|
||||
const next = await renderProbe('pty-ids', true)
|
||||
act(() => ids.push(next.latest().pendingChips.begin(undefined, 'second.pdf')))
|
||||
expect(new Set(ids).size).toBe(2)
|
||||
expect(nativeChatPendingAttachmentSnapshot('pty-ids')).toHaveLength(2)
|
||||
act(() => next.root.unmount())
|
||||
} finally {
|
||||
now.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('inserts a stored file at the caret while the composer is showing and not composing', async () => {
|
||||
const probe = await renderProbe('pty-caret', true)
|
||||
act(() =>
|
||||
probe.latest().pendingChips.attachReferences(['/srv/agent-session-attachments/u6/a.pdf'])
|
||||
)
|
||||
act(() => {
|
||||
const chips = probe.latest().pendingChips
|
||||
const id = chips.begin(undefined, 'a.pdf')
|
||||
if (!id) {
|
||||
throw new Error('expected pending chip')
|
||||
}
|
||||
chips.attachReferences([{ id, path: '/srv/agent-session-attachments/u6/a.pdf' }])
|
||||
})
|
||||
expect(probe.draft()).toBe('@/srv/agent-session-attachments/u6/a.pdf ')
|
||||
expect(readNativeChatDraftCache('pty-caret')).toBe('')
|
||||
act(() => probe.root.unmount())
|
||||
@@ -555,8 +578,7 @@ describe('useNativeChatComposerAttachments', () => {
|
||||
const id: string = chipId
|
||||
// The upload finishes mid-composition: the reference waits for the composition to settle.
|
||||
act(() => {
|
||||
expect(chips.drop(id)).toBe(true)
|
||||
chips.attachReferences(['/srv/agent-session-attachments/u4/notes.pdf'])
|
||||
chips.attachReferences([{ id, path: '/srv/agent-session-attachments/u4/notes.pdf' }])
|
||||
})
|
||||
// A prompt card takes the composer's place before the composition settles.
|
||||
act(() => probe.root.unmount())
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
type RefObject
|
||||
} from 'react'
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore, type RefObject } from 'react'
|
||||
import {
|
||||
nativeChatComposerTargetIsRemote,
|
||||
nativeChatLocalAttachmentUnsupportedNotice,
|
||||
@@ -26,17 +19,15 @@ import type { NativeChatPendingAttachmentChips } from './native-chat-session-att
|
||||
import {
|
||||
addNativeChatPendingAttachment,
|
||||
clearNativeChatPendingAttachments,
|
||||
revealNativeChatPendingAttachment,
|
||||
settleNativeChatPendingAttachment,
|
||||
settleNativeChatPendingAttachmentReferences,
|
||||
nativeChatPendingAttachmentSnapshot,
|
||||
takeNativeChatPendingAttachment,
|
||||
useNativeChatPendingAttachments
|
||||
} from './native-chat-pending-attachment-cache'
|
||||
import {
|
||||
appendNativeChatAttachmentCache,
|
||||
appendNativeChatDraftCache
|
||||
} from './native-chat-draft-cache'
|
||||
import { appendNativeChatAttachmentCache } from './native-chat-draft-cache'
|
||||
import { useNativeChatComposerAttachmentPreviews } from './use-native-chat-composer-attachment-previews'
|
||||
import { formatNativeChatFileReference } from '../../../../shared/agent-image-paste'
|
||||
import { createUuidV4 } from '../../../../shared/uuid-v4'
|
||||
|
||||
export type UseNativeChatComposerAttachmentsArgs = {
|
||||
attachmentScopeKey: string
|
||||
@@ -75,11 +66,7 @@ export function useNativeChatComposerAttachments({
|
||||
clearImageAttachments: () => void
|
||||
flushPendingAttachments: () => void
|
||||
removeImageAttachment: (id: string) => void
|
||||
beginPendingImageAttachment: (
|
||||
previewUrl?: string,
|
||||
pendingName?: string,
|
||||
options?: { hidden?: true }
|
||||
) => string | null
|
||||
beginPendingImageAttachment: (previewUrl?: string, pendingName?: string) => string | null
|
||||
resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void
|
||||
revealPendingImageAttachment: (id: string, previewUrl?: string) => void
|
||||
dropPendingImageAttachment: (id: string) => boolean
|
||||
@@ -117,19 +104,18 @@ export function useNativeChatComposerAttachments({
|
||||
],
|
||||
[pending, previews, restoring, settled]
|
||||
)
|
||||
const imageAttachmentCounter = useRef(0)
|
||||
const mountedRef = useRef(true)
|
||||
const mountedRef = useMemo(
|
||||
() => ({ scopeKey: attachmentScopeKey, current: true }),
|
||||
[attachmentScopeKey]
|
||||
)
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
}, [mountedRef])
|
||||
|
||||
const nextAttachmentId = useCallback((): string => {
|
||||
imageAttachmentCounter.current += 1
|
||||
return `${Date.now()}-${imageAttachmentCounter.current}`
|
||||
}, [])
|
||||
const nextAttachmentId = useCallback(() => createUuidV4(), [])
|
||||
|
||||
// Client-local paths cannot cross into a runtime target; workspace-owned
|
||||
// paths may only bypass this after the internal drop ownership gate.
|
||||
@@ -181,10 +167,8 @@ export function useNativeChatComposerAttachments({
|
||||
// Placeholder chip shown the instant a paste starts, so a clipboard image that
|
||||
// takes a beat to save (or upload over SSH) never reads as a dropped paste.
|
||||
const beginPendingImageAttachment = useCallback(
|
||||
(previewUrl?: string, pendingName?: string, options?: { hidden?: true }): string | null => {
|
||||
// Without image input the saved paste is attached by path once it lands, so no image chip;
|
||||
// a named upload chip is a file, not an image, and still shows.
|
||||
if (disabledRef.current || (!acceptsImages && pendingName === undefined)) {
|
||||
(previewUrl?: string, pendingName?: string): string | null => {
|
||||
if (disabledRef.current) {
|
||||
return null
|
||||
}
|
||||
if (attachmentTargetBlocked()) {
|
||||
@@ -196,14 +180,12 @@ export function useNativeChatComposerAttachments({
|
||||
id,
|
||||
path: '',
|
||||
pending: true,
|
||||
...(pendingName ? { pendingName } : {}),
|
||||
...(options?.hidden ? { hidden: true } : {})
|
||||
...(pendingName ? { pendingName } : {})
|
||||
})
|
||||
setPreview(id, previewUrl)
|
||||
return id
|
||||
},
|
||||
[
|
||||
acceptsImages,
|
||||
attachmentScopeKey,
|
||||
attachmentTargetBlocked,
|
||||
disabledRef,
|
||||
@@ -213,17 +195,35 @@ export function useNativeChatComposerAttachments({
|
||||
]
|
||||
)
|
||||
|
||||
const attachPendingReferences = useCallback(
|
||||
(references: { id: string; path: string }[]) => {
|
||||
settleNativeChatPendingAttachmentReferences(
|
||||
attachmentScopeKey,
|
||||
references,
|
||||
mountedRef.current && !disabledRef.current && !isComposing()
|
||||
? (paths) => attachResolvedPaths(paths, null)
|
||||
: undefined
|
||||
)
|
||||
},
|
||||
[attachResolvedPaths, attachmentScopeKey, disabledRef, isComposing, mountedRef]
|
||||
)
|
||||
|
||||
const resolvePendingImageAttachment = useCallback(
|
||||
(id: string, path: string, connectionId?: string | null) => {
|
||||
settleNativeChatPendingAttachment(attachmentScopeKey, id, path, connectionId)
|
||||
if (acceptsImages) {
|
||||
settleNativeChatPendingAttachment(attachmentScopeKey, id, path, connectionId)
|
||||
} else {
|
||||
attachPendingReferences([{ id, path }])
|
||||
}
|
||||
},
|
||||
[attachmentScopeKey]
|
||||
[acceptsImages, attachPendingReferences, attachmentScopeKey]
|
||||
)
|
||||
|
||||
const revealPendingImageAttachment = useCallback(
|
||||
(id: string, previewUrl?: string) => {
|
||||
setPreview(id, previewUrl)
|
||||
revealNativeChatPendingAttachment(attachmentScopeKey, id)
|
||||
if (nativeChatPendingAttachmentSnapshot(attachmentScopeKey).some((chip) => chip.id === id)) {
|
||||
setPreview(id, previewUrl)
|
||||
}
|
||||
},
|
||||
[attachmentScopeKey, setPreview]
|
||||
)
|
||||
@@ -245,23 +245,12 @@ export function useNativeChatComposerAttachments({
|
||||
drop: dropPendingImageAttachment,
|
||||
// At the caret, as every attach does; mid-composition or once the composer is gone, into the
|
||||
// scope's draft, which keeps it until the composition settles or the composer comes back.
|
||||
attachReferences: (paths) => {
|
||||
if (mountedRef.current && !isComposing()) {
|
||||
attachResolvedPaths(paths, null)
|
||||
return
|
||||
}
|
||||
appendNativeChatDraftCache(
|
||||
attachmentScopeKey,
|
||||
paths.map(formatNativeChatFileReference).join(' ')
|
||||
)
|
||||
}
|
||||
attachReferences: attachPendingReferences
|
||||
}),
|
||||
[
|
||||
attachResolvedPaths,
|
||||
attachmentScopeKey,
|
||||
attachPendingReferences,
|
||||
beginPendingImageAttachment,
|
||||
dropPendingImageAttachment,
|
||||
isComposing,
|
||||
resolvePendingImageAttachment
|
||||
]
|
||||
)
|
||||
|
||||
+3
-3
@@ -87,10 +87,10 @@ describe('structured composer image input', () => {
|
||||
expect(result.pendingChip).not.toBeNull()
|
||||
})
|
||||
|
||||
it('references an image by path, with no chip, when the agent takes none', async () => {
|
||||
it('references an image by path, with a removable pending operation, when the agent takes none', async () => {
|
||||
const result = await attach(false, '/tmp/shot.png')
|
||||
expect(result.chips).toEqual([])
|
||||
expect(result.pendingChip).toBeNull()
|
||||
expect(result.chips).toEqual([''])
|
||||
expect(result.pendingChip).not.toBeNull()
|
||||
expect(result.draft).toContain('/tmp/shot.png')
|
||||
})
|
||||
|
||||
|
||||
+8
-9
@@ -49,7 +49,7 @@ vi.stubGlobal('URL', {
|
||||
import { useNativeChatComposerPaste } from './use-native-chat-composer-paste'
|
||||
|
||||
type HookApi = ReturnType<typeof useNativeChatComposerPaste>
|
||||
type Chip = { id: string; path: string; pending: boolean; hidden?: true }
|
||||
type Chip = { id: string; path: string; pending: boolean }
|
||||
|
||||
const sessionOwner: NativeChatAttachmentOwner = {
|
||||
kind: 'runtime-session',
|
||||
@@ -87,20 +87,18 @@ async function renderPaste(args: {
|
||||
setCaret: () => {},
|
||||
resolveAttachmentOwner: () => sessionOwner,
|
||||
attachResolvedPaths: args.attachResolvedPaths ?? (() => {}),
|
||||
beginPendingImageAttachment: (_preview, _name, options) => {
|
||||
beginPendingImageAttachment: () => {
|
||||
counter += 1
|
||||
chips.push({
|
||||
id: `chip-${counter}`,
|
||||
path: '',
|
||||
pending: true,
|
||||
...(options?.hidden ? { hidden: true } : {})
|
||||
pending: true
|
||||
})
|
||||
return `chip-${counter}`
|
||||
},
|
||||
revealPendingImageAttachment: (id) => {
|
||||
const chip = chips.find((candidate) => candidate.id === id)
|
||||
if (chip) {
|
||||
delete chip.hidden
|
||||
revealed += 1
|
||||
}
|
||||
},
|
||||
@@ -151,7 +149,7 @@ beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mocks.readClipboardText.mockResolvedValue('')
|
||||
mocks.readClipboardImageThumbnail.mockResolvedValue(null)
|
||||
mocks.clipboardHasImage.mockResolvedValue(false)
|
||||
mocks.clipboardHasImage.mockResolvedValue(true)
|
||||
mocks.readClipboardFilePaths.mockResolvedValue([])
|
||||
mocks.prepareNativeChatSessionAttachmentUpload.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -195,11 +193,12 @@ describe('pasting into a structured chat on a paired server', () => {
|
||||
expect(probe.chips).toEqual([{ id: 'chip-1', path: storedPath, pending: false }])
|
||||
})
|
||||
|
||||
it('attaches the server path when no placeholder chip was shown', async () => {
|
||||
it('settles the server path when no thumbnail was shown', async () => {
|
||||
const attachResolvedPaths = vi.fn()
|
||||
const probe = await renderPaste({ attachResolvedPaths })
|
||||
await act(async () => probe.api().pasteFromClipboard())
|
||||
expect(attachResolvedPaths).toHaveBeenCalledExactlyOnceWith([storedPath], null)
|
||||
expect(probe.chips).toEqual([{ id: 'chip-1', path: storedPath, pending: false }])
|
||||
expect(attachResolvedPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses on a server without the attachment store and saves nothing', async () => {
|
||||
@@ -236,7 +235,7 @@ describe('pasting into a structured chat on a paired server', () => {
|
||||
await act(async () => probe.api().handlePaste(imagePasteEvent('caption')))
|
||||
expect(insertTypedText).toHaveBeenCalledWith('caption')
|
||||
expect(setNotice).not.toHaveBeenCalledWith('needs newer server')
|
||||
// The image was owed to the message only out of sight: no chip showed, so none flashed.
|
||||
// The operation ends quietly when this server cannot store its image.
|
||||
expect(probe.begun()).toBe(1)
|
||||
expect(probe.revealed()).toBe(0)
|
||||
expect(probe.chips).toEqual([])
|
||||
|
||||
@@ -53,6 +53,7 @@ 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)[]
|
||||
} {
|
||||
@@ -62,6 +63,12 @@ function createChipStore(): {
|
||||
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}`
|
||||
@@ -93,6 +100,7 @@ type ProbeArgs = {
|
||||
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
|
||||
@@ -134,8 +142,9 @@ async function renderProbe(args: {
|
||||
disabled,
|
||||
resolveAttachmentOwner: args.resolveAttachmentOwner,
|
||||
attachResolvedPaths: args.attachResolvedPaths ?? (() => {}),
|
||||
beginPendingImageAttachment: store.begin,
|
||||
beginPendingImageAttachment: args.agent === 'omp' ? () => null : store.begin,
|
||||
resolvePendingImageAttachment: store.resolve,
|
||||
revealPendingImageAttachment: store.reveal,
|
||||
dropPendingImageAttachment: store.drop,
|
||||
insertTypedText: args.insertTypedText ?? (() => true),
|
||||
setNotice: args.setNotice ?? (() => {}),
|
||||
@@ -175,7 +184,7 @@ beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mocks.readClipboardText.mockResolvedValue('')
|
||||
mocks.readClipboardImageThumbnail.mockResolvedValue(null)
|
||||
mocks.clipboardHasImage.mockResolvedValue(false)
|
||||
mocks.clipboardHasImage.mockResolvedValue(true)
|
||||
mocks.readClipboardFilePaths.mockResolvedValue([])
|
||||
mocks.saveClipboardImageAsTempFile.mockResolvedValue(null)
|
||||
})
|
||||
@@ -260,11 +269,7 @@ describe('useNativeChatComposerPaste', () => {
|
||||
}
|
||||
})
|
||||
expect(mocks.saveClipboardImageAsTempFile).toHaveBeenCalledWith({ connectionId: 'conn-1' })
|
||||
if (source === 'event') {
|
||||
expect(store.chips[0]?.path).toBe('/remote/tmp/omp.png')
|
||||
} else {
|
||||
expect(attachResolvedPaths).toHaveBeenCalledWith(['/remote/tmp/omp.png'], 'conn-1')
|
||||
}
|
||||
expect(attachResolvedPaths).toHaveBeenCalledWith(['/remote/tmp/omp.png'], 'conn-1')
|
||||
expect(setNotice.mock.calls.every(([notice]) => notice === null)).toBe(true)
|
||||
}
|
||||
)
|
||||
@@ -389,7 +394,7 @@ describe('useNativeChatComposerPaste', () => {
|
||||
expect(store.chips[0]).toMatchObject({ path: '/tmp/orca-paste-2.png', pending: false })
|
||||
})
|
||||
|
||||
it('attaches directly when no clipboard preview was available', async () => {
|
||||
it('settles the owned operation when no clipboard preview was available', async () => {
|
||||
mocks.readClipboardImageThumbnail.mockResolvedValue(null)
|
||||
mocks.saveClipboardImageAsTempFile.mockResolvedValue('C:\\Temp\\orca-paste-3.png')
|
||||
const store = createChipStore()
|
||||
@@ -402,8 +407,8 @@ describe('useNativeChatComposerPaste', () => {
|
||||
await act(async () => {
|
||||
probe.latest().pasteFromClipboard()
|
||||
})
|
||||
expect(store.chips).toHaveLength(0)
|
||||
expect(attachResolvedPaths).toHaveBeenCalledWith(['C:\\Temp\\orca-paste-3.png'], null)
|
||||
expect(store.chips[0]).toMatchObject({ path: 'C:\\Temp\\orca-paste-3.png', pending: false })
|
||||
expect(attachResolvedPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('inserts text independently of a failed image save', async () => {
|
||||
@@ -425,8 +430,7 @@ describe('useNativeChatComposerPaste', () => {
|
||||
})
|
||||
|
||||
it('still falls through to text when the clipboard holds no image', async () => {
|
||||
mocks.readClipboardImageThumbnail.mockResolvedValue(null)
|
||||
mocks.saveClipboardImageAsTempFile.mockResolvedValue(null)
|
||||
mocks.clipboardHasImage.mockResolvedValue(false)
|
||||
mocks.readClipboardText.mockResolvedValue('hello')
|
||||
const insertTypedText = vi.fn()
|
||||
const store = createChipStore()
|
||||
@@ -787,17 +791,21 @@ describe('file-manager copies', () => {
|
||||
mocks.saveClipboardImageAsTempFile.mockResolvedValue('/tmp/shot.png')
|
||||
const insertTypedText = vi.fn(() => true)
|
||||
const attachResolvedPaths = vi.fn()
|
||||
const store = createChipStore()
|
||||
const probe = await renderProbe({
|
||||
resolveAttachmentOwner: () => ({ kind: 'local' }),
|
||||
insertTypedText,
|
||||
attachResolvedPaths
|
||||
attachResolvedPaths,
|
||||
store
|
||||
})
|
||||
await act(async () => probe.latest().pasteFromClipboard())
|
||||
expect(insertTypedText).not.toHaveBeenCalled()
|
||||
expect(attachResolvedPaths).toHaveBeenCalledExactlyOnceWith(['/tmp/shot.png'], null)
|
||||
expect(store.chips[0]).toMatchObject({ path: '/tmp/shot.png', pending: false })
|
||||
expect(attachResolvedPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useNativeChatPasteLifetime } from './use-native-chat-paste-lifetime'
|
||||
import { nativeChatAttachmentOwnerUnchanged } from './native-chat-resolved-path-ownership'
|
||||
import { assertClipboardTextWithinLimit } from '../../../../shared/clipboard-text'
|
||||
import { setNativeChatPasteFailure } from './native-chat-composer-notice'
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import { NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES } from './native-chat-composer-target'
|
||||
import { nativeChatPasteUnavailableNotice } from '@/lib/native-chat-paste-request'
|
||||
import {
|
||||
@@ -22,33 +21,12 @@ import {
|
||||
saveNativeChatClipboardImage
|
||||
} from './native-chat-clipboard-image-save'
|
||||
|
||||
export type UseNativeChatComposerPasteArgs = {
|
||||
targetKey?: string
|
||||
agent: AgentType
|
||||
/** Live composer-disabled state (no pty / presence-lock); read at await-resume
|
||||
* via a ref so a flip mid-paste doesn't write into a guarded composer. */
|
||||
disabled: boolean
|
||||
caret: number
|
||||
/** Resolved at paste time: SSH panes must save the clipboard image on the
|
||||
* remote host, or the attached path names a file the agent cannot read. */
|
||||
resolveAttachmentOwner: () => NativeChatAttachmentOwner
|
||||
attachResolvedPaths: (paths: string[], connectionId?: string | null) => void
|
||||
beginPendingImageAttachment: (
|
||||
previewUrl?: string,
|
||||
pendingName?: string,
|
||||
options?: { hidden?: true }
|
||||
) => string | null
|
||||
resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void
|
||||
/** Shows a chip begun hidden, with its thumbnail. */
|
||||
revealPendingImageAttachment?: (id: string, previewUrl?: string) => void
|
||||
dropPendingImageAttachment: (id: string) => void
|
||||
insertTypedText: (text: string) => boolean
|
||||
setCaret: (caret: number) => void
|
||||
setNotice: (notice: string | null, errorText?: string) => void
|
||||
}
|
||||
import type { UseNativeChatComposerPasteArgs } from './native-chat-composer-paste-options'
|
||||
export type { UseNativeChatComposerPasteArgs } from './native-chat-composer-paste-options'
|
||||
|
||||
export function useNativeChatComposerPaste({
|
||||
targetKey,
|
||||
attachmentScopeKey,
|
||||
disabled,
|
||||
caret,
|
||||
resolveAttachmentOwner,
|
||||
@@ -71,6 +49,7 @@ export function useNativeChatComposerPaste({
|
||||
const { lifetime, track, startImageChip, keepStoreUploadAfterUnmount } =
|
||||
useNativeChatPasteLifetime({
|
||||
targetKey,
|
||||
attachmentScopeKey,
|
||||
beginPendingImageAttachment,
|
||||
resolvePendingImageAttachment,
|
||||
revealPendingImageAttachment,
|
||||
@@ -83,6 +62,15 @@ 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.
|
||||
@@ -112,15 +100,11 @@ export function useNativeChatComposerPaste({
|
||||
[canPaste, setNotice]
|
||||
)
|
||||
|
||||
/** Settle the chip started at paste time, or attach directly when the paste
|
||||
* produced no placeholder (no clipboard preview was available). */
|
||||
/** Settle the operation started at paste time, or attach directly if chips are unavailable. */
|
||||
const settleImagePaste = useCallback(
|
||||
(pendingId: string | null, path: string, originalOwner: NativeChatAttachmentOwner) => {
|
||||
if (!nativeChatAttachmentOwnerUnchanged(originalOwner, resolveAttachmentOwner())) {
|
||||
if (pendingId) {
|
||||
lifetime.pending.delete(pendingId)
|
||||
dropPendingImageAttachment(pendingId)
|
||||
}
|
||||
dropImagePaste(pendingId)
|
||||
setNotice(nativeChatWorktreeNotReadyNotice())
|
||||
return
|
||||
}
|
||||
@@ -135,7 +119,7 @@ export function useNativeChatComposerPaste({
|
||||
[
|
||||
attachResolvedPaths,
|
||||
lifetime,
|
||||
dropPendingImageAttachment,
|
||||
dropImagePaste,
|
||||
resolveAttachmentOwner,
|
||||
resolvePendingImageAttachment,
|
||||
setNotice
|
||||
@@ -185,28 +169,23 @@ export function useNativeChatComposerPaste({
|
||||
// Why: snapshot the caret before the async temp-file round-trip — `caret`
|
||||
// state can move (further typing/selection) while the await is in flight.
|
||||
const caretAtPaste = caret
|
||||
// Beside pasted text, a server too old to store the image drops it quietly, so its chip stays
|
||||
// out of sight until the server answers; Send waits for it from the start all the same.
|
||||
// Wait for server acceptance before minting a preview; the operation is visible from the start.
|
||||
const awaitServer = Boolean(text) && owner.kind === 'runtime-session'
|
||||
const chip = startImageChip(owner, imageFile, {
|
||||
hidden: awaitServer,
|
||||
const { id: pendingId, reveal } = startImageChip(owner, imageFile, {
|
||||
deferPreview: awaitServer,
|
||||
canShow: () => ownerAcceptsClipboardImage(owner) && canPaste()
|
||||
})
|
||||
const pendingId = chip.id
|
||||
void (async () => {
|
||||
const saved = await saveClipboardImageForOwner(
|
||||
owner,
|
||||
async () => Boolean(text),
|
||||
awaitServer ? chip.reveal : undefined
|
||||
awaitServer ? reveal : undefined
|
||||
)
|
||||
if (keepStoreUploadAfterUnmount(pendingId, saved)) {
|
||||
return
|
||||
}
|
||||
if (saved.status !== 'saved' || !canPaste()) {
|
||||
if (pendingId) {
|
||||
lifetime.pending.delete(pendingId)
|
||||
dropPendingImageAttachment(pendingId)
|
||||
}
|
||||
dropImagePaste(pendingId)
|
||||
return
|
||||
}
|
||||
settleImagePaste(pendingId, saved.tempPath, owner)
|
||||
@@ -217,9 +196,8 @@ export function useNativeChatComposerPaste({
|
||||
},
|
||||
[
|
||||
canPaste,
|
||||
lifetime,
|
||||
caret,
|
||||
dropPendingImageAttachment,
|
||||
dropImagePaste,
|
||||
insertTypedText,
|
||||
showPasteUnavailable,
|
||||
resolveAttachmentOwner,
|
||||
@@ -278,26 +256,44 @@ export function useNativeChatComposerPaste({
|
||||
}
|
||||
return
|
||||
}
|
||||
const thumbnailPromise = window.api.ui.readClipboardImageThumbnail().catch(() => null)
|
||||
const savePromise = saveClipboardImageForOwner(owner, async () => {
|
||||
const hasImage = await window.api.ui.clipboardHasImage().catch((error) => {
|
||||
if (canPaste()) {
|
||||
setNativeChatPasteFailure(setNotice, error)
|
||||
}
|
||||
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()
|
||||
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
|
||||
})
|
||||
const thumbnail = await thumbnailPromise
|
||||
const pendingId =
|
||||
thumbnail && canPaste() ? beginPendingImageAttachment(thumbnail.dataUrl) : null
|
||||
if (pendingId) {
|
||||
track(pendingId, thumbnail?.dataUrl ?? '', owner)
|
||||
}
|
||||
const saved = await savePromise
|
||||
if (keepStoreUploadAfterUnmount(pendingId, saved)) {
|
||||
return
|
||||
}
|
||||
if (!canPaste() || saved.status !== 'saved') {
|
||||
if (pendingId) {
|
||||
lifetime.pending.delete(pendingId)
|
||||
dropPendingImageAttachment(pendingId)
|
||||
}
|
||||
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) {
|
||||
@@ -309,9 +305,9 @@ export function useNativeChatComposerPaste({
|
||||
})()
|
||||
}, [
|
||||
beginPendingImageAttachment,
|
||||
revealPendingImageAttachment,
|
||||
canPaste,
|
||||
lifetime,
|
||||
dropPendingImageAttachment,
|
||||
dropImagePaste,
|
||||
insertTypedText,
|
||||
keepStoreUploadAfterUnmount,
|
||||
track,
|
||||
|
||||
+7
-6
@@ -587,9 +587,9 @@ describe('useNativeChatExternalAttachments', () => {
|
||||
'/srv/agent-session-attachments/u1/shot.png',
|
||||
null
|
||||
)
|
||||
expect(chips.drop).toHaveBeenCalledExactlyOnceWith('chip-2')
|
||||
expect(chips.drop).not.toHaveBeenCalled()
|
||||
expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([
|
||||
'/srv/agent-session-attachments/u2/notes.md'
|
||||
{ id: 'chip-2', path: '/srv/agent-session-attachments/u2/notes.md' }
|
||||
])
|
||||
// The client never reads its own disk for these paths: the server stores the bytes.
|
||||
expect(mocks.stat).not.toHaveBeenCalled()
|
||||
@@ -653,11 +653,11 @@ describe('useNativeChatExternalAttachments', () => {
|
||||
)
|
||||
)
|
||||
expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([
|
||||
'/srv/agent-session-attachments/u1/notes.md'
|
||||
{ id: 'chip-1', path: '/srv/agent-session-attachments/u1/notes.md' }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not insert a file whose chip the user removed while it uploaded', async () => {
|
||||
it('keeps each file operation ID through reference settlement, including removed chips', async () => {
|
||||
const upload = deferred<AgentSessionAttachmentPathUploadResult>()
|
||||
mocks.uploadNativeChatSessionAttachmentPaths.mockReturnValueOnce(upload.promise)
|
||||
const chips = trackingChips()
|
||||
@@ -681,7 +681,8 @@ describe('useNativeChatExternalAttachments', () => {
|
||||
)
|
||||
|
||||
expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([
|
||||
'/srv/agent-session-attachments/u2/notes.md'
|
||||
{ id: 'chip-1', path: '/srv/agent-session-attachments/u1/report.pdf' },
|
||||
{ id: 'chip-2', path: '/srv/agent-session-attachments/u2/notes.md' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -709,7 +710,7 @@ describe('useNativeChatExternalAttachments', () => {
|
||||
|
||||
expect(chips.drop).toHaveBeenCalledWith('chip-1')
|
||||
expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([
|
||||
'/srv/agent-session-attachments/u2/notes.md'
|
||||
{ id: 'chip-2', path: '/srv/agent-session-attachments/u2/notes.md' }
|
||||
])
|
||||
// The cause, the size limit here, rides in the same notice.
|
||||
expect(notices).toEqual([
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createRef } from 'react'
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { useNativeChatComposerPaste } from './use-native-chat-composer-paste'
|
||||
import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments'
|
||||
import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload'
|
||||
import { nativeChatImageSendBlock } from './native-chat-image-reattach'
|
||||
import {
|
||||
clearNativeChatComposerDraftsForTests,
|
||||
hydrateNativeChatComposerDrafts,
|
||||
nativeChatComposerDraftWritesSettled,
|
||||
readNativeChatComposerDraft,
|
||||
setNativeChatComposerDraftOwnerResolver,
|
||||
updateNativeChatComposerDraft,
|
||||
structuredAgentSessionDraftScopeKey as scope
|
||||
} from './native-chat-composer-draft-store'
|
||||
import {
|
||||
createMemoryNativeChatComposerDraftStorage,
|
||||
setNativeChatComposerDraftStorageForTests
|
||||
} from './native-chat-composer-draft-storage'
|
||||
import {
|
||||
clearNativeChatPendingAttachmentsForTests,
|
||||
dropNativeChatPendingAttachmentsOwnedBy,
|
||||
nativeChatPendingAttachmentSnapshot
|
||||
} from './native-chat-pending-attachment-cache'
|
||||
|
||||
let previousApi: typeof window.api
|
||||
const draftOwner = { workspaceId: 'folder-1', executionHostId: 'local' as const }
|
||||
beforeEach(async () => {
|
||||
previousApi = window.api
|
||||
setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage())
|
||||
await hydrateNativeChatComposerDrafts()
|
||||
setNativeChatComposerDraftOwnerResolver(() => draftOwner)
|
||||
})
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
cleanup()
|
||||
await nativeChatComposerDraftWritesSettled()
|
||||
clearNativeChatPendingAttachmentsForTests()
|
||||
clearNativeChatComposerDraftsForTests()
|
||||
window.api = previousApi
|
||||
})
|
||||
|
||||
async function fixture(
|
||||
kind: 'local' | 'ssh',
|
||||
acceptsImages = true,
|
||||
source: 'menu' | 'event' = 'menu'
|
||||
) {
|
||||
const save = Promise.withResolvers<string | null>()
|
||||
const thumbnail = Promise.withResolvers<{
|
||||
dataUrl: string
|
||||
width: number
|
||||
height: number
|
||||
} | null>()
|
||||
const owner: NativeChatAttachmentOwner =
|
||||
kind === 'ssh'
|
||||
? {
|
||||
kind: 'ssh',
|
||||
connectionId: 'ssh-1',
|
||||
worktreePath: '/remote/folder',
|
||||
expectedExecutionHostId: 'ssh:ssh-1',
|
||||
expectedSshTargetId: 'ssh-1',
|
||||
expectedSshConnectionGeneration: 1
|
||||
}
|
||||
: { kind: 'local' }
|
||||
const path =
|
||||
kind === 'ssh'
|
||||
? '/tmp/orca-paste-1-image.png'
|
||||
: '/local/native-chat-pastes/orca-paste-1-image.png'
|
||||
const saveCall = vi.fn(() => save.promise)
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
ui: {
|
||||
readClipboardText: async () => '',
|
||||
readClipboardFilePaths: async () => [],
|
||||
clipboardHasImage: async () => true,
|
||||
readClipboardImageThumbnail: () => thumbnail.promise,
|
||||
saveClipboardImageAsTempFile: saveCall
|
||||
}
|
||||
}
|
||||
})
|
||||
const input = createRef<NativeChatComposerInput>()
|
||||
const view = renderHook(() => {
|
||||
const attachments = useNativeChatComposerAttachments({
|
||||
attachmentScopeKey: scope('menu'),
|
||||
allowWithoutTarget: true,
|
||||
acceptsImages,
|
||||
caret: 0,
|
||||
disabled: false,
|
||||
isComposing: () => false,
|
||||
resolveTarget: () => null,
|
||||
textareaRef: input,
|
||||
setCaret: () => {},
|
||||
setDraft: (update) =>
|
||||
updateNativeChatComposerDraft(
|
||||
scope('menu'),
|
||||
{
|
||||
text: update(readNativeChatComposerDraft(scope('menu')).text)
|
||||
},
|
||||
'immediate'
|
||||
),
|
||||
setNotice: () => {}
|
||||
})
|
||||
const paste = useNativeChatComposerPaste({
|
||||
targetKey: 'menu',
|
||||
attachmentScopeKey: scope('menu'),
|
||||
agent: acceptsImages ? 'claude' : 'omp',
|
||||
disabled: false,
|
||||
caret: 0,
|
||||
resolveAttachmentOwner: () => owner,
|
||||
...attachments,
|
||||
insertTypedText: () => true,
|
||||
setCaret: () => {},
|
||||
setNotice: () => {}
|
||||
})
|
||||
return { paste, attachments }
|
||||
})
|
||||
await act(async () => {
|
||||
if (source === 'menu') {
|
||||
view.result.current.paste.pasteFromClipboard()
|
||||
} else {
|
||||
const data = new DataTransfer()
|
||||
data.items.add(new File(['png'], 'image.png', { type: 'image/png' }))
|
||||
view.result.current.paste.handlePaste(new ClipboardEvent('paste', { clipboardData: data }))
|
||||
}
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(saveCall).toHaveBeenCalledExactlyOnceWith(
|
||||
kind === 'ssh' ? { connectionId: 'ssh-1' } : { forNativeChatDraft: true }
|
||||
)
|
||||
)
|
||||
return { view, save, thumbnail, path }
|
||||
}
|
||||
|
||||
const referencePastes = [
|
||||
{ kind: 'local', source: 'menu' },
|
||||
{ kind: 'ssh', source: 'menu' },
|
||||
{ kind: 'local', source: 'event' },
|
||||
{ kind: 'ssh', source: 'event' }
|
||||
] as const
|
||||
|
||||
it.each(
|
||||
referencePastes.flatMap((paste) =>
|
||||
[true, false].map((acceptsImages) => ({ ...paste, acceptsImages }))
|
||||
)
|
||||
)(
|
||||
'can abandon stalled $kind $source operations after reopen with acceptsImages=$acceptsImages',
|
||||
async ({ kind, source, acceptsImages }) => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
|
||||
const { view, save, thumbnail, path } = await fixture(kind, acceptsImages, source)
|
||||
view.unmount()
|
||||
const reopened = renderHook(() =>
|
||||
useNativeChatComposerAttachments({
|
||||
attachmentScopeKey: scope('menu'),
|
||||
acceptsImages,
|
||||
allowWithoutTarget: true,
|
||||
caret: 0,
|
||||
disabled: false,
|
||||
isComposing: () => false,
|
||||
resolveTarget: () => null,
|
||||
textareaRef: createRef<NativeChatComposerInput>(),
|
||||
setCaret: () => {},
|
||||
setDraft: () => {},
|
||||
setNotice: () => {}
|
||||
})
|
||||
)
|
||||
await act(async () => vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000))
|
||||
expect(reopened.result.current.imageAttachments).toHaveLength(1)
|
||||
expect(nativeChatImageSendBlock(reopened.result.current.imageAttachments).holdsSend).toBe(true)
|
||||
const id = reopened.result.current.imageAttachments[0].id
|
||||
await act(async () => reopened.result.current.removeImageAttachment(id))
|
||||
expect(nativeChatImageSendBlock(reopened.result.current.imageAttachments).holdsSend).toBe(false)
|
||||
await act(async () => save.resolve(path))
|
||||
await act(async () =>
|
||||
thumbnail.resolve({ dataUrl: 'data:image/png;base64,AA', width: 1, height: 1 })
|
||||
)
|
||||
expect(nativeChatPendingAttachmentSnapshot(scope('menu'))).toEqual([])
|
||||
expect(readNativeChatComposerDraft(scope('menu')).images).toEqual([])
|
||||
expect(readNativeChatComposerDraft(scope('menu')).text).toBe('')
|
||||
}
|
||||
)
|
||||
it.each(referencePastes)(
|
||||
'keeps a $kind $source paste as a reference after close',
|
||||
async ({ kind, source }) => {
|
||||
const { view, save, thumbnail, path } = await fixture(kind, false, source)
|
||||
await act(async () =>
|
||||
thumbnail.resolve({ dataUrl: 'data:image/png;base64,AA', width: 1, height: 1 })
|
||||
)
|
||||
expect(view.result.current.attachments.imageAttachments).toHaveLength(1)
|
||||
view.unmount()
|
||||
await act(async () => save.resolve(path))
|
||||
expect(nativeChatPendingAttachmentSnapshot(scope('menu'))).toEqual([])
|
||||
expect(readNativeChatComposerDraft(scope('menu')).text).toContain(path)
|
||||
expect(readNativeChatComposerDraft(scope('menu')).images).toEqual([])
|
||||
}
|
||||
)
|
||||
it.each(referencePastes)(
|
||||
'keeps mounted $kind $source paste references without image chips',
|
||||
async ({ kind, source }) => {
|
||||
const { save, thumbnail, path } = await fixture(kind, false, source)
|
||||
await act(async () => thumbnail.resolve(null))
|
||||
await act(async () => save.resolve(path))
|
||||
expect(readNativeChatComposerDraft(scope('menu')).text).toContain(path)
|
||||
expect(readNativeChatComposerDraft(scope('menu')).images).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['local', 'ssh'] as const)(
|
||||
'preserves menu %s uploads closed before a delayed or absent thumbnail',
|
||||
async (kind) => {
|
||||
let completed = 0
|
||||
for (const preview of ['delayed', 'missing', 'present'] as const) {
|
||||
const { view, save, thumbnail, path } = await fixture(kind)
|
||||
if (preview !== 'delayed') {
|
||||
await act(async () =>
|
||||
thumbnail.resolve(
|
||||
preview === 'present'
|
||||
? { dataUrl: 'data:image/png;base64,AA', width: 1, height: 1 }
|
||||
: null
|
||||
)
|
||||
)
|
||||
}
|
||||
view.unmount()
|
||||
await act(async () => save.resolve(path))
|
||||
expect(nativeChatPendingAttachmentSnapshot(scope('menu'))).toEqual([])
|
||||
expect(readNativeChatComposerDraft(scope('menu')).images).toHaveLength(++completed)
|
||||
expect(readNativeChatComposerDraft(scope('menu')).images.at(-1)).toMatchObject({
|
||||
path,
|
||||
...(kind === 'ssh' ? { connectionId: 'ssh-1' } : {})
|
||||
})
|
||||
// The save also settles when a thumbnail has not answered at all.
|
||||
if (preview === 'delayed') {
|
||||
await act(async () => thumbnail.resolve(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['local', 'ssh'] as const)(
|
||||
'settles menu %s images without a thumbnail while mounted',
|
||||
async (kind) => {
|
||||
const { save, thumbnail, path } = await fixture(kind)
|
||||
await act(async () => thumbnail.resolve(null))
|
||||
await act(async () => save.resolve(path))
|
||||
expect(readNativeChatComposerDraft(scope('menu')).images[0]).toMatchObject({ path })
|
||||
}
|
||||
)
|
||||
|
||||
it.each([true, false])(
|
||||
'ends pending operations for acceptsImages=%s on removal, deletion or failure',
|
||||
async (acceptsImages) => {
|
||||
for (const end of ['remove', 'workspace', 'failure'] as const) {
|
||||
const { view, save, thumbnail, path } = await fixture('local', acceptsImages)
|
||||
if (end === 'remove') {
|
||||
await act(async () => view.result.current.attachments.clearImageAttachments())
|
||||
} else if (end === 'workspace') {
|
||||
dropNativeChatPendingAttachmentsOwnedBy(draftOwner)
|
||||
}
|
||||
view.unmount()
|
||||
await act(async () => save.resolve(end === 'failure' ? null : path))
|
||||
await act(async () =>
|
||||
thumbnail.resolve({ dataUrl: 'data:image/png;base64,AA', width: 1, height: 1 })
|
||||
)
|
||||
expect(nativeChatPendingAttachmentSnapshot(scope('menu'))).toEqual([])
|
||||
expect(readNativeChatComposerDraft(scope('menu')).images).toEqual([])
|
||||
expect(readNativeChatComposerDraft(scope('menu')).text).toBe('')
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,141 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest'
|
||||
import { useNativeChatPasteLifetime } from './use-native-chat-paste-lifetime'
|
||||
import {
|
||||
clearNativeChatComposerDraftsForTests,
|
||||
hydrateNativeChatComposerDrafts,
|
||||
nativeChatComposerDraftWritesSettled,
|
||||
readNativeChatComposerDraft,
|
||||
setNativeChatComposerDraftOwnerResolver,
|
||||
structuredAgentSessionDraftScopeKey as scope
|
||||
} from './native-chat-composer-draft-store'
|
||||
import {
|
||||
type NativeChatComposerDraftOwner,
|
||||
createMemoryNativeChatComposerDraftStorage,
|
||||
setNativeChatComposerDraftStorageForTests
|
||||
} from './native-chat-composer-draft-storage'
|
||||
import {
|
||||
addNativeChatPendingAttachment,
|
||||
clearNativeChatPendingAttachmentsForTests,
|
||||
dropNativeChatPendingAttachmentsOwnedBy,
|
||||
nativeChatPendingAttachmentSnapshot,
|
||||
settleNativeChatPendingAttachment,
|
||||
takeNativeChatPendingAttachment
|
||||
} from './native-chat-pending-attachment-cache'
|
||||
import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload'
|
||||
|
||||
const WORKSPACE: NativeChatComposerDraftOwner = {
|
||||
workspaceId: 'workspace-1',
|
||||
executionHostId: 'local'
|
||||
}
|
||||
const owners: NativeChatAttachmentOwner[] = [
|
||||
{ kind: 'local' },
|
||||
{
|
||||
kind: 'ssh',
|
||||
connectionId: 'ssh-1',
|
||||
worktreePath: '/remote/worktree',
|
||||
expectedExecutionHostId: 'ssh:ssh-1',
|
||||
expectedSshTargetId: 'ssh-1',
|
||||
expectedSshConnectionGeneration: 1
|
||||
},
|
||||
{ kind: 'runtime-session', environmentId: 'paired', sessionId: 'a', pairingRevision: 1 }
|
||||
]
|
||||
|
||||
beforeEach(async () => {
|
||||
setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage())
|
||||
await hydrateNativeChatComposerDrafts()
|
||||
setNativeChatComposerDraftOwnerResolver(() => WORKSPACE)
|
||||
})
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
await nativeChatComposerDraftWritesSettled()
|
||||
clearNativeChatPendingAttachmentsForTests()
|
||||
clearNativeChatComposerDraftsForTests()
|
||||
})
|
||||
|
||||
function fixture(owner: NativeChatAttachmentOwner) {
|
||||
const view = renderHook(
|
||||
({ draftScope }) =>
|
||||
useNativeChatPasteLifetime({
|
||||
targetKey: draftScope,
|
||||
attachmentScopeKey: draftScope,
|
||||
beginPendingImageAttachment: () => 'upload',
|
||||
resolvePendingImageAttachment: (id, path, connectionId) => {
|
||||
settleNativeChatPendingAttachment(draftScope, id, path, connectionId)
|
||||
},
|
||||
dropPendingImageAttachment: (id) => {
|
||||
takeNativeChatPendingAttachment(draftScope, id)
|
||||
}
|
||||
}),
|
||||
{ initialProps: { draftScope: scope('a') } }
|
||||
)
|
||||
addNativeChatPendingAttachment(scope('a'), { id: 'upload', path: '', pending: true })
|
||||
view.result.current.track('upload', '', owner)
|
||||
return { view, lifetime: view.result.current }
|
||||
}
|
||||
|
||||
it.each(owners)('keeps a $kind upload when its composer closes', (owner) => {
|
||||
const { view, lifetime } = fixture(owner)
|
||||
view.unmount()
|
||||
expect(nativeChatPendingAttachmentSnapshot(scope('a'))).toHaveLength(1)
|
||||
expect(
|
||||
lifetime.keepStoreUploadAfterUnmount('upload', {
|
||||
status: 'saved',
|
||||
tempPath: '/stored/image.png'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(nativeChatPendingAttachmentSnapshot(scope('a'))).toEqual([])
|
||||
expect(readNativeChatComposerDraft(scope('a')).images).toEqual([
|
||||
{
|
||||
id: 'upload',
|
||||
path: '/stored/image.png',
|
||||
...(owner.kind === 'ssh' ? { connectionId: 'ssh-1' } : {})
|
||||
}
|
||||
])
|
||||
expect(readNativeChatComposerDraft(scope('b')).images).toEqual([])
|
||||
})
|
||||
|
||||
it.each(owners)('keeps a $kind upload with its original scope after a target switch', (owner) => {
|
||||
const { view, lifetime } = fixture(owner)
|
||||
view.rerender({ draftScope: scope('b') })
|
||||
expect(
|
||||
lifetime.keepStoreUploadAfterUnmount('upload', {
|
||||
status: 'saved',
|
||||
tempPath: '/stored/image.png'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(readNativeChatComposerDraft(scope('a')).images).toHaveLength(1)
|
||||
expect(readNativeChatComposerDraft(scope('b')).images).toEqual([])
|
||||
})
|
||||
|
||||
it.each(owners)('does not settle a removed $kind upload', (owner) => {
|
||||
const { view, lifetime } = fixture(owner)
|
||||
takeNativeChatPendingAttachment(scope('a'), 'upload')
|
||||
view.unmount()
|
||||
lifetime.keepStoreUploadAfterUnmount('upload', { status: 'saved', tempPath: '/stored/image.png' })
|
||||
expect(readNativeChatComposerDraft(scope('a')).images).toEqual([])
|
||||
})
|
||||
|
||||
it.each(owners)('drops a failed $kind upload after teardown', (owner) => {
|
||||
const { view, lifetime } = fixture(owner)
|
||||
view.unmount()
|
||||
expect(lifetime.keepStoreUploadAfterUnmount('upload', { status: 'error' })).toBe(true)
|
||||
expect(nativeChatPendingAttachmentSnapshot(scope('a'))).toEqual([])
|
||||
expect(readNativeChatComposerDraft(scope('a')).images).toEqual([])
|
||||
})
|
||||
|
||||
it.each(owners)(
|
||||
'does not recreate a draft after its workspace is removed during a $kind upload',
|
||||
(owner) => {
|
||||
const { view, lifetime } = fixture(owner)
|
||||
view.unmount()
|
||||
dropNativeChatPendingAttachmentsOwnedBy(WORKSPACE)
|
||||
lifetime.keepStoreUploadAfterUnmount('upload', {
|
||||
status: 'saved',
|
||||
tempPath: '/stored/image.png'
|
||||
})
|
||||
expect(nativeChatPendingAttachmentSnapshot(scope('a'))).toEqual([])
|
||||
expect(readNativeChatComposerDraft(scope('a')).images).toEqual([])
|
||||
}
|
||||
)
|
||||
@@ -1,19 +1,14 @@
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload'
|
||||
import { nativeChatPendingAttachmentSnapshot } from './native-chat-pending-attachment-cache'
|
||||
|
||||
/**
|
||||
* The pending chips of pastes this composer started, released when it unmounts or changes target.
|
||||
* A paste uploading into a paired server's store outlives the composer, which a prompt card
|
||||
* unmounts: its chip stays pending in the scope's attachment cache, where its result settles for
|
||||
* the composer's return (`native-chat-pending-attachment-cache.ts`).
|
||||
* Preview URLs belong to this composer; pending uploads belong to the draft's attachment cache.
|
||||
*/
|
||||
export function useNativeChatPasteLifetime(args: {
|
||||
targetKey?: string
|
||||
beginPendingImageAttachment: (
|
||||
previewUrl?: string,
|
||||
pendingName?: string,
|
||||
options?: { hidden?: true }
|
||||
) => string | null
|
||||
attachmentScopeKey?: string
|
||||
beginPendingImageAttachment: (previewUrl?: string, pendingName?: string) => string | null
|
||||
/** Files the result into the composer's scope even once this instance is unmounted. */
|
||||
resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void
|
||||
revealPendingImageAttachment?: (id: string, previewUrl?: string) => void
|
||||
@@ -21,19 +16,23 @@ export function useNativeChatPasteLifetime(args: {
|
||||
}): {
|
||||
lifetime: { active: boolean; pending: Map<string, string> }
|
||||
track: (pendingId: string, preview: string, owner: NativeChatAttachmentOwner) => void
|
||||
/** A paste's chip, shown at once with the clipboard's own thumbnail, or held out of sight
|
||||
* (`hidden`) until `reveal`; `id` is null when the composer refused it. */
|
||||
/** Register the visible operation; `reveal` supplies its preview once the host accepts it. */
|
||||
startImageChip: (
|
||||
owner: NativeChatAttachmentOwner,
|
||||
imageFile: Blob,
|
||||
options: { hidden: boolean; canShow: () => boolean }
|
||||
options: { deferPreview: boolean; canShow: () => boolean }
|
||||
) => { id: string | null; reveal: () => void }
|
||||
keepStoreUploadAfterUnmount: (
|
||||
pendingId: string | null,
|
||||
saved: { status: string; tempPath?: string }
|
||||
) => boolean
|
||||
} {
|
||||
const { targetKey, beginPendingImageAttachment, revealPendingImageAttachment } = args
|
||||
const {
|
||||
targetKey,
|
||||
attachmentScopeKey,
|
||||
beginPendingImageAttachment,
|
||||
revealPendingImageAttachment
|
||||
} = args
|
||||
const { resolvePendingImageAttachment, dropPendingImageAttachment } = args
|
||||
const dropPendingRef = useRef(dropPendingImageAttachment)
|
||||
useLayoutEffect(() => {
|
||||
@@ -42,11 +41,12 @@ export function useNativeChatPasteLifetime(args: {
|
||||
const lifetime = useMemo(
|
||||
() => ({
|
||||
targetKey,
|
||||
attachmentScopeKey,
|
||||
active: false,
|
||||
pending: new Map<string, string>(),
|
||||
storeUploads: new Set<string>()
|
||||
uploads: new Map<string, NativeChatAttachmentOwner>()
|
||||
}),
|
||||
[targetKey]
|
||||
[attachmentScopeKey, targetKey]
|
||||
)
|
||||
useLayoutEffect(() => {
|
||||
lifetime.active = true
|
||||
@@ -56,19 +56,23 @@ export function useNativeChatPasteLifetime(args: {
|
||||
if (preview.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(preview)
|
||||
}
|
||||
if (!lifetime.storeUploads.has(id)) {
|
||||
if (
|
||||
lifetime.uploads.get(id)?.kind !== 'runtime-session' &&
|
||||
!(
|
||||
attachmentScopeKey &&
|
||||
nativeChatPendingAttachmentSnapshot(attachmentScopeKey).some((chip) => chip.id === id)
|
||||
)
|
||||
) {
|
||||
dropPendingRef.current(id)
|
||||
}
|
||||
}
|
||||
lifetime.pending.clear()
|
||||
}
|
||||
}, [lifetime])
|
||||
}, [attachmentScopeKey, lifetime])
|
||||
const track = useCallback(
|
||||
(pendingId: string, preview: string, owner: NativeChatAttachmentOwner) => {
|
||||
lifetime.pending.set(pendingId, preview)
|
||||
if (owner.kind === 'runtime-session') {
|
||||
lifetime.storeUploads.add(pendingId)
|
||||
}
|
||||
lifetime.uploads.set(pendingId, owner)
|
||||
},
|
||||
[lifetime]
|
||||
)
|
||||
@@ -76,16 +80,22 @@ export function useNativeChatPasteLifetime(args: {
|
||||
(
|
||||
owner: NativeChatAttachmentOwner,
|
||||
imageFile: Blob,
|
||||
options: { hidden: boolean; canShow: () => boolean }
|
||||
options: { deferPreview: boolean; canShow: () => boolean }
|
||||
): { id: string | null; reveal: () => void } => {
|
||||
if (options.hidden) {
|
||||
const id = beginPendingImageAttachment(undefined, undefined, { hidden: true })
|
||||
if (options.deferPreview) {
|
||||
const id = beginPendingImageAttachment()
|
||||
if (id) {
|
||||
track(id, '', owner)
|
||||
}
|
||||
// Through the scope's cache, so it shows even in a composer that came back since.
|
||||
const reveal = (): void => {
|
||||
if (!id) {
|
||||
if (
|
||||
!id ||
|
||||
(attachmentScopeKey &&
|
||||
!nativeChatPendingAttachmentSnapshot(attachmentScopeKey).some(
|
||||
(chip) => chip.id === id
|
||||
))
|
||||
) {
|
||||
return
|
||||
}
|
||||
const previewUrl = lifetime.active ? URL.createObjectURL(imageFile) : undefined
|
||||
@@ -108,21 +118,42 @@ export function useNativeChatPasteLifetime(args: {
|
||||
}
|
||||
return { id, reveal: () => {} }
|
||||
},
|
||||
[beginPendingImageAttachment, lifetime, revealPendingImageAttachment, track]
|
||||
[attachmentScopeKey, beginPendingImageAttachment, lifetime, revealPendingImageAttachment, track]
|
||||
)
|
||||
const keepStoreUploadAfterUnmount = useCallback(
|
||||
(pendingId: string | null, saved: { status: string; tempPath?: string }): boolean => {
|
||||
if (!pendingId || lifetime.active || !lifetime.storeUploads.has(pendingId)) {
|
||||
if (!pendingId || lifetime.active) {
|
||||
if (pendingId) {
|
||||
lifetime.uploads.delete(pendingId)
|
||||
}
|
||||
return false
|
||||
}
|
||||
const owner = lifetime.uploads.get(pendingId)
|
||||
if (
|
||||
owner?.kind !== 'runtime-session' &&
|
||||
!(
|
||||
attachmentScopeKey &&
|
||||
nativeChatPendingAttachmentSnapshot(attachmentScopeKey).some(
|
||||
(chip) => chip.id === pendingId
|
||||
)
|
||||
)
|
||||
) {
|
||||
lifetime.uploads.delete(pendingId)
|
||||
return false
|
||||
}
|
||||
lifetime.uploads.delete(pendingId)
|
||||
if (saved.status === 'saved' && saved.tempPath) {
|
||||
resolvePendingImageAttachment(pendingId, saved.tempPath, null)
|
||||
resolvePendingImageAttachment(
|
||||
pendingId,
|
||||
saved.tempPath,
|
||||
owner?.kind === 'ssh' ? owner.connectionId : null
|
||||
)
|
||||
} else {
|
||||
dropPendingImageAttachment(pendingId)
|
||||
}
|
||||
return true
|
||||
},
|
||||
[dropPendingImageAttachment, lifetime, resolvePendingImageAttachment]
|
||||
[attachmentScopeKey, dropPendingImageAttachment, lifetime, resolvePendingImageAttachment]
|
||||
)
|
||||
return { lifetime, track, startImageChip, keepStoreUploadAfterUnmount }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user