+
{
+ const field = event.target.closest('[data-native-chat-composer-scope]')
+ if (field) {
+ setActiveScope(field.dataset.nativeChatComposerScope ?? null)
+ }
+ }}
+ onPointerDownCapture={(event) => {
+ const field =
+ event.target instanceof Element
+ ? event.target.closest('[data-native-chat-composer-scope]')
+ : null
+ if (field) {
+ setActiveScope(field.dataset.nativeChatComposerScope ?? null)
+ }
+ }}
+ >
{children}
{isDragActive ? : null}
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
index 87e7859dae0c..99c23ed84090 100644
--- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
@@ -37,8 +37,8 @@ import { NativeChatPendingIcon } from './NativeChatPendingIcon'
import type { QueuedMessagesPendingAction } from './use-structured-agent-session-queued-messages'
import { useNativeChatClippedLine } from './use-native-chat-clipped-line'
import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure'
-import { NativeChatQueuedMessageEditor } from './NativeChatQueuedMessageEditor'
-import type { QueuedEditAttachTarget } from './use-native-chat-queued-edit-image-intake'
+import { NativeChatQueuedMessageComposer } from './NativeChatQueuedMessageComposer'
+import type { QueuedEditAttachTarget } from './NativeChatQueuedMessageComposer'
import type { QueuedMessageInlineEditor } from './use-structured-agent-session-queued-edit'
import { nativeChatImageLabelCopy } from './native-chat-image-label'
@@ -171,7 +171,7 @@ export function NativeChatQueuedMessageCard({
return (
// The editor fills the row, so a drop anywhere on the card reaches it.
- (null)
+ const row = useRef(null)
+ useLayoutEffect(() => {
+ if (editor.acquiring) {
+ return
+ }
+ composer.current?.focus()
+ }, [editor.acquiring])
+ useLayoutEffect(() => {
+ const element = row.current
+ const list = element?.closest('ul')
+ if (!element || !list) {
+ return
+ }
+ const reveal = (): void => {
+ const bounds = list.getBoundingClientRect()
+ const viewportTop = bounds.top + list.clientTop
+ const viewportBottom = viewportTop + list.clientHeight
+ if (!list.clientHeight) {
+ return
+ }
+ const content = element.getBoundingClientRect()
+ const delta =
+ content.height > list.clientHeight
+ ? content.bottom - viewportBottom
+ : content.top < viewportTop
+ ? content.top - viewportTop
+ : Math.max(0, content.bottom - viewportBottom)
+ if (delta) {
+ list.scrollTop += delta
+ }
+ }
+ reveal()
+ const observer = new ResizeObserver(reveal)
+ observer.observe(element)
+ observer.observe(list)
+ return () => observer.disconnect()
+ }, [])
+ return (
+
+
+
+
+ {translate('components.native-chat.queuedMessages.editingMessage', 'Editing message')}
+
+ {sender}
+ {!editor.imagesCapable ? images : null}
+
+ {attachTo ? (
+
+ ) : null}
+
+ )
+}
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx
deleted file mode 100644
index 29ca12c13913..000000000000
--- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx
+++ /dev/null
@@ -1,548 +0,0 @@
-// @vitest-environment happy-dom
-import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { useEffect, useRef, type ReactNode } from 'react'
-import { TooltipProvider } from '@/components/ui/tooltip'
-import { NativeChatQueuedMessageEditor } from './NativeChatQueuedMessageEditor'
-import { NativeChatApprovalCard } from './NativeChatApprovalCard'
-import { NativeChatQuestionCard } from './NativeChatQuestionCard'
-import {
- useStructuredAgentSessionQueuedEdit,
- type QueuedMessageInlineEditor
-} from './use-structured-agent-session-queued-edit'
-import { projectQueuedMessageCards } from '../../../../shared/structured-agent-session-queued-cards'
-import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire'
-import type { StructuredAgentSessionWrite } from './use-structured-agent-session-mutate'
-import type * as NativeChatAttachmentUpload from './native-chat-attachment-upload'
-import type * as I18n from '@/i18n/i18n'
-import {
- handOffQueuedEdit,
- queuedEditImageFields,
- type QueuedEditImageState
-} from './structured-agent-session-queued-edit-images'
-import {
- readNativeChatAttachmentCache,
- readNativeChatDraftCache,
- appendNativeChatAttachmentCache
-} from './native-chat-draft-cache'
-import { queuedEditInput, queuedEditDirty } from '../../../../shared/queued-message-edit'
-import { reserveNativeChatAttachmentChips as reserve } from './native-chat-session-attachment-drop'
-import * as ExternalAttachments from './use-native-chat-external-attachments'
-import { imageReferenceToken } from '../../../../shared/native-chat-image-reference'
-import { handleAppMenuPasteRequest } from '@/lib/app-menu-paste'
-import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge'
-import type { NativeChatComposerHandle } from './native-chat-composer-types'
-import { routeNativeChatRootKeyToInput } from './native-chat-root-key-routing'
-import * as OsFileDropOwner from '@/hooks/use-os-file-drop-owner'
-import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
-
-const externalAttachments = ExternalAttachments.useNativeChatExternalAttachments
-let chips: Parameters[0]['pendingChips']
-beforeEach(() => {
- vi.spyOn(ExternalAttachments, 'useNativeChatExternalAttachments').mockImplementation((args) => {
- chips = args.pendingChips
- return externalAttachments(args)
- })
-})
-vi.mock('@/i18n/i18n', async (original) => ({
- ...(await original()),
- translate: (_key: string, fallback: string, values?: Record) =>
- fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => values?.[name] ?? '')
-}))
-vi.mock('@/runtime/structured-agent-session-client', () => ({
- callStructuredAgentSession: async (
- _target: unknown,
- _method: string,
- params: { action: string }
- ) =>
- params.action === 'release'
- ? { status: 'released' }
- : {
- status: 'held',
- fingerprint: 'f'.repeat(64),
- leaseDurationMs: 120_000,
- remainingMs: 120_000
- }
-}))
-vi.mock('../../store', () => ({ useAppStore: { getState: () => ({}) } }))
-vi.mock('@/components/editor/useLocalImageSrc', () => ({ useLocalImageSrc: () => undefined }))
-vi.mock('./native-chat-attachment-upload', async (original) => ({
- ...(await original()),
- resolveNativeChatAttachmentOwnerForWorktree: () => ({ kind: 'local' })
-}))
-vi.mock('./native-chat-clipboard-image-save', () => ({
- ownerAcceptsClipboardImage: () => true,
- saveNativeChatPastedClipboardImage: async () => ({ status: 'saved', tempPath: '/tmp/new.png' })
-}))
-afterEach(cleanup)
-function editor(): QueuedMessageInlineEditor {
- return {
- messageId: 'mail',
- text: 'full message',
- acquiring: false,
- saving: false,
- canSave: true,
- change: vi.fn(),
- save: vi.fn(),
- cancel: vi.fn()
- }
-}
-function view(
- edit: QueuedMessageInlineEditor,
- prompt: 'question' | 'approval' | null,
- answer = vi.fn(),
- promptCancel = vi.fn()
-) {
- return (
-
-
- {prompt === 'approval' ? (
-
- ) : null}
- {prompt === 'question' ? (
-
- ) : null}
-
- )
-}
-describe('queued card textarea', () => {
- it.each(['question', 'approval'] as const)(
- 'stays mounted and focused when a %s opens, its keys apart from the prompt',
- (prompt) => {
- const edit = editor()
- const answer = vi.fn()
- const promptCancel = vi.fn()
- const { rerender } = render(view(edit, null, answer, promptCancel))
- const field = screen.getByRole('textbox', { name: 'Edit message' })
- expect(document.activeElement).toBe(field)
- rerender(view(edit, prompt, answer, promptCancel))
- expect(document.activeElement).toBe(field)
- fireEvent.keyDown(field, { key: 'Enter' })
- expect(edit.save).toHaveBeenCalledOnce()
- expect(answer).not.toHaveBeenCalled()
- fireEvent.keyDown(field, { key: 'Escape' })
- expect(edit.cancel).toHaveBeenCalledOnce()
- expect(promptCancel).not.toHaveBeenCalled()
- }
- )
- it.each(['question', 'approval'] as const)(
- 'focuses and saves with a %s already open',
- (prompt) => {
- const edit = editor()
- render(view(edit, prompt))
- const field = screen.getByRole('textbox', { name: 'Edit message' })
- expect(document.activeElement).toBe(field)
- fireEvent.click(screen.getByRole('button', { name: 'Save' }))
- expect(edit.save).toHaveBeenCalledOnce()
- }
- )
- it('Enter and the platform modifier save, Shift+Enter is a newline and handled keys do not bubble', () => {
- const edit = editor()
- const parent = vi.fn()
- render(
-
-
-
- )
- const field = screen.getByRole('textbox')
- fireEvent.keyDown(field, { key: 'Enter' })
- expect(edit.save).toHaveBeenCalledOnce()
- expect(parent).not.toHaveBeenCalled()
- fireEvent.keyDown(field, { key: 'Enter', shiftKey: true })
- expect(edit.save).toHaveBeenCalledOnce()
- for (const [platform, modifier] of [
- ['Macintosh', { metaKey: true }],
- ['Linux', { ctrlKey: true }],
- ['Windows', { ctrlKey: true }]
- ] as const) {
- vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(platform)
- fireEvent.keyDown(field, { key: 'Enter', ...modifier })
- }
- expect(edit.save).toHaveBeenCalledTimes(4)
- vi.restoreAllMocks()
- })
- it('IME confirmation and Escape never commit/cancel or reach the prompt', () => {
- const edit = editor()
- const parent = vi.fn()
- render(
-
-
-
- )
- const field = screen.getByRole('textbox')
- fireEvent.compositionStart(field)
- fireEvent.keyDown(field, { key: 'Enter', isComposing: true })
- fireEvent.keyDown(field, { key: 'Escape', isComposing: true })
- fireEvent.compositionEnd(field)
- fireEvent.keyDown(field, { key: 'Enter', keyCode: 229 })
- expect(edit.save).not.toHaveBeenCalled()
- expect(edit.cancel).not.toHaveBeenCalled()
- expect(parent).not.toHaveBeenCalled()
- })
- it('acquisition seeds a read-only field with the X always usable', () => {
- const edit = { ...editor(), acquiring: true }
- render()
- expect(screen.getByRole('textbox').getAttribute('readonly')).not.toBeNull()
- expect(screen.getByRole('button', { name: 'Save' }).hasAttribute('disabled')).toBe(true)
- fireEvent.click(screen.getByRole('button', { name: 'Cancel editing' }))
- expect(edit.cancel).toHaveBeenCalledOnce()
- })
- it('the header X cancels and saves nothing; Save is the only footer action', () => {
- const edit = editor()
- render()
- expect(screen.getByText('Editing message')).toBeTruthy()
- fireEvent.click(screen.getByRole('button', { name: 'Cancel editing' }))
- expect(edit.cancel).toHaveBeenCalledOnce()
- expect(edit.save).not.toHaveBeenCalled()
- expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull()
- expect(
- screen
- .getAllByRole('button')
- .map((button) => button.getAttribute('aria-label') ?? button.textContent)
- ).toEqual(['Cancel editing', 'Save'])
- })
- it.each([
- { state: 'saving', edit: { saving: true } },
- { state: 'unchanged', edit: { canSave: false } }
- ])('Save is disabled while $state and enabled otherwise', ({ edit: overrides }) => {
- const edit = { ...editor(), ...overrides }
- const { rerender } = render()
- const save = screen.getByRole('button', { name: 'Save' })
- expect(save.hasAttribute('disabled')).toBe(true)
- fireEvent.click(save)
- expect(edit.save).not.toHaveBeenCalled()
- rerender()
- fireEvent.click(screen.getByRole('button', { name: 'Save' }))
- expect(edit.save).toHaveBeenCalledOnce()
- })
- it('Save carries a decorative Enter badge and no key hint text', () => {
- for (const platform of ['Macintosh', 'Windows']) {
- vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(platform)
- const { container, unmount } = render()
- const save = screen.getByRole('button', { name: 'Save' })
- const badge = save.querySelector('[aria-hidden="true"]')
- expect(badge?.querySelector('svg')).toBeTruthy()
- expect(badge?.textContent).toBe('')
- expect(container.textContent).not.toMatch(/Shift|⇧|new line/)
- unmount()
- }
- vi.restoreAllMocks()
- })
-})
-
-const card: AgentSessionQueuedMessage = {
- messageId: 'card',
- position: 1,
- state: 'waiting',
- body: {
- kind: 'message',
- role: 'user',
- blocks: [
- { type: 'text', text: 'replace WORD please [Image #1] and [Image #2]' },
- { type: 'image-ref', path: '/host/old.png' },
- { type: 'image-ref', path: '/host/kept.png' }
- ]
- }
-}
-const writeSpy = vi.fn(async (..._args: unknown[]) => ({
- kind: 'done',
- value: { status: 'updated', messageId: 'card', fingerprint: 'a'.repeat(64) }
-}))
-// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: answers only queuedMessageUpdate, with that method's result.
-const write = writeSpy as StructuredAgentSessionWrite
-
-function EditedCard({
- imagesCapable,
- acceptsImages = true,
- runtimeEnvironmentId = null
-}: {
- imagesCapable: boolean
- acceptsImages?: boolean
- runtimeEnvironmentId?: string | null
-}): React.JSX.Element | null {
- const messages = [card]
- const { editor, begin } = useStructuredAgentSessionQueuedEdit({
- transport: { target: { kind: 'local' }, sessionId: 's', capable: true, imagesCapable, write },
- messages,
- cards: projectQueuedMessageCards(messages, [], {}),
- submissions: [],
- composerScopeKey: 'draft',
- promptOpen: false
- })
- useEffect(() => void begin('card'), [begin])
- const attachTo = {
- terminalTabId: 'tab',
- transport: { sessionId: 's', worktreeId: 'wt', runtimeEnvironmentId, acceptsImages }
- }
- return editor ? (
-
-
-
- ) : null
-}
-
-function savedParams(): unknown {
- return writeSpy.mock.calls.at(-1)?.[2]
-}
-
-function PastePane({
- composer,
- children
-}: {
- composer: NativeChatComposerHandle
- children: ReactNode
-}): React.JSX.Element {
- const rootRef = useRef(null)
- const composerRef = useRef(composer)
- useNativeChatPasteBridge({ rootRef, composerRef })
- return (
- routeNativeChatRootKeyToInput(event, composer, null)}
- >
- {children}
-
- )
-}
-
-describe('queued card image edit', () => {
- it.each([null, 'paired'])(
- 'prepares for the captured queued owner %s',
- async (runtimeEnvironmentId) => {
- const owner = vi.spyOn(OsFileDropOwner, 'useOsFileDropOwner')
- try {
- replaceRuntimeEnvironmentRevisions([{ id: 'paired', createdAt: 1, pairingRevision: 7 }])
- render()
- await waitFor(() => expect(screen.getByRole('textbox')).toHaveProperty('readOnly', false))
- const options = owner.mock.calls.at(-1)![1]
- const destination = options.captureDestination?.(new DragEvent('drop'))
- const consumer =
- typeof options.consumer === 'function' ? options.consumer(destination) : options.consumer
- expect(consumer).toBe(runtimeEnvironmentId ? 'agent' : 'local-chat')
- } finally {
- replaceRuntimeEnvironmentRevisions([])
- owner.mockRestore()
- }
- }
- )
-
- it('pastes into the owning queued edit through native key and menu delivery', async () => {
- let field: HTMLElement
- const ui = {
- readClipboardText: vi.fn(),
- performNativePaste: vi.fn(() => {
- expect(document.activeElement).toBe(field)
- })
- }
- vi.stubGlobal('api', { ui, fs: { getPathForFile: () => '' } })
- vi.stubGlobal('navigator', { userAgent: 'Windows', clipboard: undefined })
- const composer: NativeChatComposerHandle = {
- focus: vi.fn(() => true),
- insertTypedText: () => true,
- acceptsText: () => true,
- appendText: vi.fn(),
- handlePasteEvent: vi.fn(),
- pasteFromClipboard: vi.fn(),
- contains: () => false
- }
- try {
- render(
-
-
-
- )
- const attach = await screen.findByRole('button', { name: 'Attach file' })
- field = screen.getByRole('textbox', { name: 'Edit message' })
- await waitFor(() => expect(field).toHaveProperty('readOnly', false))
- attach.focus()
- expect(fireEvent.keyDown(attach, { key: 'v', ctrlKey: true })).toBe(true)
- expect(document.activeElement).toBe(field)
- const data = new DataTransfer()
- data.setData('text/plain', 'pasted text')
- data.items.add(new File(['png'], 'pasted.png', { type: 'image/png' }))
- fireEvent.paste(field, { clipboardData: data })
- await waitFor(() =>
- expect(screen.getAllByRole('button', { name: 'Remove attachment' })).toHaveLength(3)
- )
- expect(field).toHaveProperty('value', expect.stringContaining('pasted text'))
- attach.focus()
- await act(
- async () =>
- void (await handleAppMenuPasteRequest({
- readClipboardText: ui.readClipboardText,
- performNativePaste: ui.performNativePaste
- }))
- )
- expect(ui.performNativePaste).toHaveBeenCalledExactlyOnceWith({ mode: 'paste' })
- expect(ui.readClipboardText).not.toHaveBeenCalled()
- expect(composer.pasteFromClipboard).not.toHaveBeenCalled()
- expect(composer.handlePasteEvent).not.toHaveBeenCalled()
- expect(composer.focus).not.toHaveBeenCalled()
- } finally {
- vi.restoreAllMocks()
- vi.unstubAllGlobals()
- }
- })
-
- it('keeps image intake text-neutral, saves suffixes and hands off only surviving changes', async () => {
- writeSpy.mockClear()
- render()
- const field = await screen.findByRole('textbox', { name: 'Edit message' })
- await waitFor(() => expect(field.getAttribute('readonly')).toBeNull())
- fireEvent.click(screen.getAllByRole('button', { name: 'Remove attachment' })[0]!)
- // The field shows what the agent will read: the removed image's marker goes, #2 becomes #1.
- expect(field).toHaveProperty('value', 'replace WORD please and [Image #1]')
- const data = new DataTransfer()
- data.setData('text/plain', 'X')
- data.items.add(new File(['png'], 'new.png', { type: 'image/png' }))
- if (!(field instanceof HTMLTextAreaElement)) {
- throw new Error('Expected the edit textarea')
- }
- field.focus()
- field.setSelectionRange(8, 12)
- act(() => {
- const [first, second] = reserve(chips, ['/tmp/first.png', '/tmp/second.png'])
- chips.resolve(second!.chipId, second!.path)
- chips.resolve(first!.chipId, first!.path)
- })
- expect(field.value).toBe('replace WORD please and [Image #1]')
- expect([field.selectionStart, field.selectionEnd]).toEqual([8, 12])
- act(() => {
- field.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true }))
- })
- await waitFor(() => expect(field.value).toBe('replace X please and [Image #1]'))
- await waitFor(() =>
- expect(screen.getByRole('button', { name: 'Save' })).toHaveProperty('disabled', false)
- )
- fireEvent.change(field, { target: { value: 'abc def' } })
- field.setSelectionRange(4, 4)
- act(() => {
- const [file, image] = reserve(chips, ['/repo/notes.txt', '/tmp/file.png'])
- chips.resolve(image!.chipId, image!.path)
- chips.attachReferences([{ id: file!.chipId, path: file!.path }])
- })
- fireEvent.click(screen.getAllByRole('button', { name: 'Remove attachment' })[3]!)
- expect(field.value).toBe('abc @/repo/notes.txt def')
- fireEvent.click(screen.getByRole('button', { name: 'Save' }))
- await waitFor(() => expect(writeSpy).toHaveBeenCalledOnce())
- expect(savedParams()).toMatchObject({
- text: 'abc @/repo/notes.txt def [Image #2] [Image #3] [Image #4] ',
- images: [
- { type: 'image-ref', path: '/host/kept.png' },
- { type: 'image-ref', path: '/tmp/first.png' },
- { type: 'image-ref', path: '/tmp/second.png' },
- { type: 'image-ref', path: '/tmp/file.png' }
- ]
- })
- const edit: QueuedEditImageState = {
- draftKey: 'r6-handoff',
- body: card.body,
- text: ' original wording\n[Image #1] \n',
- ...queuedEditImageFields(
- card.body,
- () => edit,
- () => true,
- () => {}
- )
- }
- edit.body = { ...card.body, blocks: [{ type: 'text', text: edit.text }] }
- edit.kept = []
- const original = edit.text
- for (const exit of ['pending', 'drop', 'remove'] as const) {
- const pending = edit.intake.begin()!
- if (exit === 'remove') {
- edit.intake.resolve(pending, '/tmp/new.png')
- }
- if (exit !== 'pending') {
- edit.intake[exit](pending)
- }
- expect(edit.text).toBe(original)
- expect(handOffQueuedEdit(edit)).toBe(false)
- expect(readNativeChatDraftCache(edit.draftKey)).toBe('')
- edit.intake.drop(pending)
- expect(queuedEditDirty(edit.body, edit)).toBe(false)
- }
- edit.intake.append(['/tmp/new.png'])
- const moved = edit.added[0]!
- expect(queuedEditInput(edit.body, edit, true)).toEqual(queuedEditInput(edit.body, edit, true))
- expect(edit.text).toBe(original)
- expect(handOffQueuedEdit(edit)).toBe(true)
- expect(readNativeChatAttachmentCache(edit.draftKey)).toEqual([
- { id: moved.id, path: moved.path }
- ])
- expect(readNativeChatDraftCache(edit.draftKey)).toBe(`${imageReferenceToken(moved.id)} `)
- edit.draftKey += '-typed'
- appendNativeChatAttachmentCache(edit.draftKey, [{ id: 'destination', path: '/tmp/dest.png' }])
- edit.kept = [{ id: 'kept', block: { type: 'image-ref', path: '/host/kept.png' } }]
- edit.text = 'typed [Image #1] not [Image #2]'
- expect(handOffQueuedEdit(edit)).toBe(true)
- expect(readNativeChatDraftCache(edit.draftKey)).toBe(
- `typed not [Image #2] ${imageReferenceToken(moved.id)} `
- )
- expect(readNativeChatAttachmentCache(edit.draftKey).map(({ id }) => id)).toEqual([
- 'destination',
- moved.id
- ])
- })
-
- it('never saves an image change as text alone once the host stops reading as taking images', async () => {
- writeSpy.mockClear()
- const { rerender } = render()
- const field = await screen.findByRole('textbox', { name: 'Edit message' })
- await waitFor(() => expect(field.getAttribute('readonly')).toBeNull())
- fireEvent.click(screen.getAllByRole('button', { name: 'Remove attachment' })[0]!)
- rerender()
- const save = screen.getByRole('button', { name: 'Save' })
- expect(save).toHaveProperty('disabled', true)
- fireEvent.keyDown(field, { key: 'Enter' })
- expect(writeSpy).not.toHaveBeenCalled()
- })
-
- it('against a host that edits text only, shows the count and saves text alone', async () => {
- writeSpy.mockClear()
- render()
- const field = await screen.findByRole('textbox', { name: 'Edit message' })
- await waitFor(() => expect(field.getAttribute('readonly')).toBeNull())
- expect(screen.getByText('1 image')).toBeTruthy()
- expect(screen.queryByRole('button', { name: 'Remove attachment' })).toBeNull()
- expect(screen.queryByRole('button', { name: 'Attach file' })).toBeNull()
- fireEvent.change(field, { target: { value: 'see that' } })
- fireEvent.click(screen.getByRole('button', { name: 'Save' }))
- await waitFor(() => expect(writeSpy).toHaveBeenCalledOnce())
- expect(savedParams()).toEqual({
- messageId: 'card',
- expectedBodyFingerprint: expect.any(String),
- text: 'see that'
- })
- })
-})
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.tsx
deleted file mode 100644
index 3eb86fb32fa8..000000000000
--- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.tsx
+++ /dev/null
@@ -1,172 +0,0 @@
-import { useLayoutEffect, useRef, type ReactNode } from 'react'
-import { Pencil, Plus, X } from 'lucide-react'
-import { Textarea } from '@/components/ui/textarea'
-import { Button } from '@/components/ui/button'
-import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
-import { translate } from '@/i18n/i18n'
-import { ButtonKeyHint } from '@/components/ButtonKeyHint'
-import { isMacPlatform } from './native-chat-shortcut'
-import { NativeChatImageAttachmentPreview } from './NativeChatImageAttachmentPreview'
-import { NativeChatPaneFileDropOverlay } from './NativeChatPaneFileDropSurface'
-import type { QueuedMessageInlineEditor } from './use-structured-agent-session-queued-edit'
-import {
- useNativeChatQueuedEditImageIntake,
- type QueuedEditAttachTarget
-} from './use-native-chat-queued-edit-image-intake'
-
-/** Edit inside the card so questions and approvals occupying the composer do not block queued-draft editing. */
-export function NativeChatQueuedMessageEditor({
- editor,
- sender,
- images,
- attachTo
-}: {
- editor: QueuedMessageInlineEditor
- /** The card's "From " line, kept beside the label. */
- sender?: ReactNode
- /** The card's image count, shown where the host edits text only and so keeps the images. */
- images?: ReactNode
- /** The chat this card's added files attach to, as its chat box attaches them. */
- attachTo?: QueuedEditAttachTarget
-}): React.JSX.Element {
- const field = useRef(null)
- const intake = useNativeChatQueuedEditImageIntake(editor, attachTo, field)
- const chips = editor.images?.chips ?? []
- const empty = editor.images?.empty === true
- useLayoutEffect(() => {
- if (editor.acquiring) {
- return
- }
- const textarea = field.current
- textarea?.focus({ preventScroll: true })
- const list = textarea?.closest('ul')
- const row = textarea?.closest('li')
- if (list && row) {
- const rowBounds = row.getBoundingClientRect()
- const listBounds = list.getBoundingClientRect()
- if (rowBounds.top < listBounds.top) {
- list.scrollTop += rowBounds.top - listBounds.top
- } else if (rowBounds.bottom > listBounds.bottom) {
- list.scrollTop += rowBounds.bottom - listBounds.bottom
- }
- }
- }, [editor.acquiring])
- return (
-
- {intake.isDragActive ?
: null}
-
-
-
- {translate('components.native-chat.queuedMessages.editingMessage', 'Editing message')}
-
- {sender ? (
- <>
-
·
-
{sender}
- >
- ) : null}
-
{editor.images ? null : images}
-
-
- {chips.length > 0 ? (
-
- {chips.map((chip) => (
- editor.images?.intake.remove(id)}
- />
- ))}
-
- ) : null}
-
- )
-}
-
-function QueuedEditAttachButton({ onAttach }: { onAttach: () => void }): React.JSX.Element {
- const label = translate('components.native-chat.composer.attach', 'Attach file')
- return (
-
-
-
-
-
- {label}
-
-
- )
-}
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.inline-edit.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.inline-edit.test.tsx
index c1c38f5666df..10ad4ccb6b0d 100644
--- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.inline-edit.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.inline-edit.test.tsx
@@ -2,6 +2,8 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { NativeChatComposerProps } from './native-chat-composer-types'
+import type { QueuedEditAttachTarget } from './NativeChatQueuedMessageComposer'
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
// A card's sender line opens and names agents through modules this test does not exercise.
@@ -18,6 +20,46 @@ vi.mock('../../store', () => {
return { useAppStore }
})
+vi.mock('./NativeChatComposer', async () => {
+ const React = await import('react')
+ return {
+ NativeChatComposer: React.forwardRef(
+ (props, ref) => (
+ <>
+
+
+ >
+ )
+ )
+ }
+})
+
+const editAttachTo: QueuedEditAttachTarget = {
+ terminalTabId: 'tab',
+ transport: {
+ capturePresentation: () => () => true,
+ send: () => false,
+ dispatchCommand: async () => {
+ throw new Error('not used by the queue renderer')
+ },
+ optionsSurface: {
+ getSnapshot: () => [],
+ setOption: async () => {
+ throw new Error('not used')
+ },
+ invokeAction: async () => {
+ throw new Error('not used')
+ },
+ subscribe: () => () => {}
+ },
+ optionSnapshot: [],
+ onError: vi.fn(),
+ runtime: 'local',
+ sessionId: 'session',
+ runtimeEnvironmentId: null
+ }
+}
+
import { TooltipProvider } from '@/components/ui/tooltip'
import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards'
import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList'
@@ -26,7 +68,11 @@ import type { StructuredAgentSessionQueuedMessagesController } from './use-struc
function renderList(owner: StructuredAgentSessionQueuedMessagesController) {
return render(
-
+
)
}
@@ -64,7 +110,11 @@ function controller(
}
}
-afterEach(() => cleanup())
+afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+ vi.unstubAllGlobals()
+})
describe('NativeChatQueuedMessageList inline edit', () => {
it('reorders only user cards from the grip with arrow keys and retains focus', async () => {
@@ -101,6 +151,8 @@ describe('NativeChatQueuedMessageList inline edit', () => {
it('the card being edited shows its editor in place of its text and actions; others cannot open one', async () => {
const editor = {
messageId: 'draft-1',
+ draftScopeKey: 'edit-scope',
+ imagesCapable: true,
text: 'editing',
acquiring: false,
saving: false,
@@ -130,6 +182,8 @@ describe('NativeChatQueuedMessageList inline edit', () => {
it('an edited card shows the editing header with its sender and hides its caption until editing ends', () => {
const editor = {
messageId: 'mail',
+ draftScopeKey: 'edit-scope',
+ imagesCapable: true,
text: 'editing',
acquiring: false,
saving: false,
@@ -156,6 +210,7 @@ describe('NativeChatQueuedMessageList inline edit', () => {
{
const focusComposer = vi.fn()
const editor = {
messageId: 'draft-1',
+ draftScopeKey: 'edit-scope',
+ imagesCapable: true,
text: 'editing',
acquiring: false,
saving: false,
@@ -188,6 +245,7 @@ describe('NativeChatQueuedMessageList inline edit', () => {
@@ -210,3 +268,93 @@ describe('NativeChatQueuedMessageList inline edit', () => {
}
})
})
+
+// The active production row owns exactly two observations, independent of queue size.
+it.each([10, 100])(
+ 'reveals active editor/footer on size changes with %i cards and releases ownership',
+ (count) => {
+ const observers: {
+ callback: () => void
+ observe: ReturnType
+ disconnect: ReturnType
+ }[] = []
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ constructor(readonly callback: () => void) {
+ observers.push(this)
+ }
+ observe = vi.fn()
+ disconnect = vi.fn()
+ }
+ )
+ let height = 200,
+ viewport = 288
+ const original = HTMLElement.prototype.getBoundingClientRect
+ const measured = vi
+ .spyOn(HTMLElement.prototype, 'getBoundingClientRect')
+ .mockImplementation(function (this: HTMLElement) {
+ if (this.tagName === 'UL') {
+ return DOMRect.fromRect({ y: 100, height: viewport })
+ }
+ if (this.hasAttribute('data-queued-message-editor')) {
+ return DOMRect.fromRect({ y: 150 - (this.closest('ul')?.scrollTop ?? 0), height })
+ }
+ return original.call(this)
+ })
+ vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(function (
+ this: HTMLElement
+ ) {
+ return this.tagName === 'UL' ? viewport : 0
+ })
+ const focus = vi.spyOn(HTMLTextAreaElement.prototype, 'focus')
+ const editor = {
+ messageId: 'active',
+ draftScopeKey: 'scope',
+ text: 'edit',
+ imagesCapable: true,
+ acquiring: false,
+ saving: false,
+ canSave: true,
+ change: vi.fn(),
+ save: vi.fn(),
+ cancel: vi.fn()
+ }
+ const cards = [
+ card({ messageId: 'active' }),
+ ...Array.from({ length: count - 1 }, (_, i) => card({ messageId: `other-${i}` }))
+ ]
+ const view = renderList(controller(cards, null, true, { editCapable: true, editor }))
+ const scrollport = screen.getByRole('list', { name: 'Queued messages' })
+ const observer = observers.find((entry) =>
+ entry.observe.mock.calls.some(([node]) => node === scrollport)
+ )!
+ const resize = observer.callback
+ expect(observer.observe.mock.calls.map(([node]) => node)).toEqual([
+ view.container.querySelector('[data-queued-message-editor]'),
+ scrollport
+ ])
+ expect(scrollport.scrollTop).toBe(0)
+ // Notice/image/text growth: oversized editor exposes its trailing actions.
+ height = 323
+ resize()
+ expect(scrollport.scrollTop).toBe(85)
+ expect(focus).toHaveBeenCalledOnce()
+ const beforeManual = measured.mock.calls.length
+ scrollport.scrollTop = 0
+ fireEvent.scroll(scrollport)
+ expect(measured.mock.calls.length).toBe(beforeManual)
+ expect(scrollport.scrollTop).toBe(0)
+ viewport = 180
+ resize()
+ expect(scrollport.scrollTop).toBe(193)
+ // Shrink back to a fitting row: minimally reveal its top, within the queue only.
+ height = 100
+ resize()
+ expect(scrollport.scrollTop).toBe(50)
+ expect(view.container.scrollTop).toBe(0)
+ expect(focus).toHaveBeenCalledOnce()
+ view.unmount()
+ expect(observer.disconnect).toHaveBeenCalledOnce()
+ }
+)
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx
index 58f878f757f7..1f0925586f3f 100644
--- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx
@@ -19,7 +19,7 @@ import type { AgentSessionFailureFact } from '../../../../shared/agent-session-f
import { NATIVE_CHAT_ROOT_SELECTOR } from '@/lib/native-chat-paste-request'
import { focusNativeChatPromptCard } from './use-native-chat-prompt-card-focus'
import { queuedMessageCardNeedsAttention } from '../../../../shared/structured-agent-session-queued-cards'
-import type { QueuedEditAttachTarget } from './use-native-chat-queued-edit-image-intake'
+import type { QueuedEditAttachTarget } from './NativeChatQueuedMessageComposer'
/** Keep host-held drafts outside the transcript until consumed; keep the live region mounted for the first card. */
export function NativeChatQueuedMessageList({
@@ -128,7 +128,7 @@ export function NativeChatQueuedMessageList({
// Bound queue height to preserve transcript/composer space; allow extra room for edit thumbnails.
className={cn(
'scrollbar-sleek divide-y divide-border overflow-y-auto',
- controller.editor?.images?.chips.length ? 'max-h-72' : 'max-h-40'
+ controller.editor ? 'max-h-72' : 'max-h-40'
)}
>
{
+ cleanup()
+ vi.restoreAllMocks()
+ clearNativeChatPendingAttachmentsForTests()
+ clearNativeChatComposerDraftsForTests()
+})
+
+it.each([20, 200])(
+ 'isolates mounted draft, pending and composing append consumers across %i scopes',
+ async (count) => {
+ const scopes = [
+ 'queued-edit:own-host:target',
+ ...Array.from({ length: count }, (_, i) => `queued-edit:other-host:${i}`)
+ ]
+ const own = scopes[0]
+ const textRenders = new Map()
+ const pendingRenders = new Map()
+ const drafts = new Map>()
+ let composing = false
+ const isComposing = () => composing
+ const upstream = scopes.map(() => ({ draft: vi.fn(), pending: vi.fn(), append: vi.fn() }))
+ const releases = scopes.flatMap((scope, index) => {
+ openNativeChatEditDraft(scope, { text: 'seed', images: [] })
+ return [
+ subscribeToNativeChatComposerDraft(scope, upstream[index].draft),
+ subscribeToNativeChatPendingAttachments(scope, upstream[index].pending),
+ subscribeToNativeChatComposerDraftAppend(scope, upstream[index].append)
+ ]
+ })
+ function TextConsumer({ scope }: { scope: string }) {
+ const draft = useNativeChatDraft(scope, isComposing)
+ drafts.set(scope, draft)
+ textRenders.set(scope, (textRenders.get(scope) ?? 0) + 1)
+ return {draft.draft}
+ }
+ function PendingConsumer({ scope }: { scope: string }) {
+ const chips = useNativeChatPendingAttachments(scope)
+ pendingRenders.set(scope, (pendingRenders.get(scope) ?? 0) + 1)
+ return {chips.length}
+ }
+ const view = render(
+ <>
+ {scopes.map((scope) => (
+
+ ))}
+ >
+ )
+ await act(async () => {})
+ textRenders.clear()
+ pendingRenders.clear()
+ act(() =>
+ updateNativeChatComposerDraft(
+ own,
+ { images: [{ id: 'saved', path: '/saved.png' }] },
+ 'immediate'
+ )
+ )
+ expect(textRenders.size).toBe(0)
+ expect(pendingRenders.size).toBe(0)
+ expect(upstream[0].draft).toHaveBeenCalledTimes(1)
+ act(() => updateNativeChatComposerDraft(own, { text: 'own' }, 'immediate'))
+ expect(textRenders.get(own)).toBe(1)
+ expect(drafts.get(own)?.draft).toBe('own')
+ expect(pendingRenders.size).toBe(0)
+ act(() => addNativeChatPendingAttachment(own, { id: 'pending', path: '', pending: true }))
+ expect(textRenders.get(own)).toBe(1)
+ expect(pendingRenders.get(own)).toBe(1)
+ composing = true
+ act(() => appendToNativeChatComposerDraft(own, { text: 'returned' }))
+ expect(drafts.get(own)?.draft).toBe('own')
+ expect(upstream[0].append).toHaveBeenCalledTimes(1)
+ act(() => drafts.get(own)!.flushDraftAppends())
+ expect(drafts.get(own)?.draft).toBe('own\n\nreturned')
+ expect(textRenders.size).toBe(1)
+ expect(pendingRenders.size).toBe(1)
+ expect(upstream[0].draft).toHaveBeenCalledTimes(3)
+ expect(upstream[0].pending).toHaveBeenCalledTimes(1)
+ for (const idle of upstream.slice(1)) {
+ expect(idle.draft).not.toHaveBeenCalled()
+ expect(idle.pending).not.toHaveBeenCalled()
+ expect(idle.append).not.toHaveBeenCalled()
+ }
+ view.unmount()
+ releases.forEach((release) => release())
+ textRenders.clear()
+ pendingRenders.clear()
+ act(() => updateNativeChatComposerDraft(own, { text: 'after unmount' }, 'immediate'))
+ act(() => addNativeChatPendingAttachment(own, { id: 'after', path: '', pending: true }))
+ expect(textRenders.size + pendingRenders.size).toBe(0)
+ expect(upstream[0].draft).toHaveBeenCalledTimes(3)
+ expect(upstream[0].pending).toHaveBeenCalledTimes(1)
+ }
+)
+
+it.each([20, 200])(
+ 'completes moved and cancelled chips without inspecting %i unrelated scopes',
+ (count) => {
+ let inspected = 0
+ for (let i = 0; i < count; i++) {
+ const scope = `other-${i}`
+ addNativeChatPendingAttachment(scope, { id: `chip-${i}`, path: '', pending: true })
+ Object.defineProperty(nativeChatPendingAttachmentSnapshot(scope)[0], 'id', {
+ get() {
+ inspected++
+ return `chip-${i}`
+ }
+ })
+ }
+ addNativeChatPendingAttachment('source', { id: 'moved', path: '', pending: true })
+ moveNativeChatPendingAttachments('source', 'destination')
+ inspected = 0
+ expect(settleNativeChatPendingAttachment('source', 'moved', '/moved.png')).toBe(true)
+ expect(settleNativeChatPendingAttachment('source', 'moved', '/again.png')).toBe(false)
+ addNativeChatPendingAttachment('source', { id: 'cancel', path: '', pending: true })
+ clearNativeChatPendingAttachments('source')
+ expect(settleNativeChatPendingAttachment('source', 'cancel', '/late.png')).toBe(false)
+ expect(inspected).toBe(0)
+ }
+)
+
+it.each([20, 200])(
+ 'bounds destination lookup by this pane, independent of %i other panes',
+ (count) => {
+ const releases: (() => void)[] = []
+ const holder = document.createElement('div')
+ document.body.append(holder)
+ let reads = 0
+ const handle: NativeChatComposerHandle = {
+ focus: () => true,
+ insertTypedText: () => true,
+ acceptsText: () => true,
+ appendText: () => {},
+ handlePasteEvent: () => {},
+ pasteFromClipboard: () => {},
+ contains: () => true
+ }
+ const pane = document.createElement('div')
+ const input = document.createElement('textarea')
+ pane.append(input)
+ holder.append(pane)
+ const owned = {
+ get current() {
+ reads++
+ return handle
+ }
+ }
+ releases.push(
+ registerNativeChatComposerDestination(input, owned, true, 'captured-scope'),
+ registerDictationComposerTarget(pane, () => {})
+ )
+ for (let i = 0; i < count; i++) {
+ const other = document.createElement('div')
+ holder.append(other)
+ releases.push(
+ registerNativeChatComposerDestination(
+ other,
+ {
+ get current(): NativeChatComposerHandle | null {
+ throw new Error('unrelated handle read')
+ }
+ },
+ true,
+ `other-scope-${i}`
+ ),
+ registerDictationComposerTarget(other, () => {})
+ )
+ }
+ input.focus()
+ const contains = vi.spyOn(HTMLElement.prototype, 'contains')
+ expect(nativeChatComposerDestination(pane, null)).toBe(handle)
+ expect(captureInsertionTarget()?.kind).toBe('composer')
+ expect(reads).toBe(1)
+ expect(nativeChatComposerScopeDestination('captured-scope')).toBe(handle)
+ expect(reads).toBe(2)
+ expect(contains).not.toHaveBeenCalled()
+ releases.forEach((release) => release())
+ expect(nativeChatComposerDestination(pane, null)).toBeNull()
+ expect(nativeChatComposerScopeDestination('captured-scope')).toBeNull()
+ holder.remove()
+ }
+)
+
+it.each([10, 100])(
+ 'measures autocomplete only for its own scroll ancestors across %i menus and cleans up',
+ (count) => {
+ const measure = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
+ const view = render(
+ <>
+ {Array.from({ length: count }, (_, index) => (
+
+ {}}
+ />
+
+ ))}
+ >
+ )
+ expect(measure).toHaveBeenCalledTimes(count)
+ measure.mockClear()
+ act(() => view.container.querySelector('[data-scroll="0"]')!.dispatchEvent(new Event('scroll')))
+ expect(measure).toHaveBeenCalledTimes(1)
+ measure.mockClear()
+ act(() => window.dispatchEvent(new Event('resize')))
+ expect(measure).toHaveBeenCalledTimes(count)
+ measure.mockClear()
+ view.unmount()
+ window.dispatchEvent(new Event('resize'))
+ expect(measure).not.toHaveBeenCalled()
+ }
+)
+
+// The actual capture route runs before the input skip gate.
+it.each([20, 200])(
+ 'routes a real key with constant local handle reads beside %i other panes',
+ (count) => {
+ const releases: (() => void)[] = []
+ let localReads = 0
+ const inserted = vi.fn(() => true)
+ const handle: NativeChatComposerHandle = {
+ focus: () => true,
+ insertTypedText: inserted,
+ acceptsText: () => true,
+ appendText: () => {},
+ handlePasteEvent: () => {},
+ pasteFromClipboard: () => {},
+ contains: () => false
+ }
+ function Pane() {
+ const rootRef = useRef(null)
+ const composerRef = useRef(handle)
+ const questionAnswerInputRef = useRef(null)
+ const routing = useNativeChatRootInputRouting({
+ tabId: 'scaling',
+ rootRef,
+ composerRef,
+ questionAnswerInputRef,
+ isVisible: false,
+ isFocusedGroup: false,
+ composerReady: false
+ })
+ useLayoutEffect(
+ () =>
+ registerNativeChatComposerDestination(
+ rootRef.current!,
+ {
+ get current() {
+ localReads++
+ return handle
+ }
+ },
+ true
+ ),
+ []
+ )
+ return (
+
+
+ transcript
+
+ )
+ }
+ for (let i = 0; i < count; i++) {
+ const other = document.createElement('div')
+ document.body.append(other)
+ releases.push(
+ registerNativeChatComposerDestination(
+ other,
+ {
+ get current(): NativeChatComposerHandle | null {
+ throw new Error('unrelated handle read')
+ }
+ },
+ true,
+ `other-scope-${i}`
+ ),
+ () => other.remove()
+ )
+ }
+ const view = render()
+ fireEvent.keyDown(view.getByLabelText('owned'), { key: 'a' })
+ expect(inserted).not.toHaveBeenCalled()
+ fireEvent.keyDown(view.getByText('transcript'), { key: 'b' })
+ expect(inserted).toHaveBeenCalledExactlyOnceWith('b')
+ expect(localReads).toBe(2)
+ releases.forEach((release) => release())
+ }
+)
+
+it.each([20, 200])(
+ 'hand-back visits only its own paste operation beside %i unrelated operations',
+ (count) => {
+ const ownId = '00000000-0000-4000-8000-000000000001'
+ let unrelatedReads = 0
+ const edit = createNativeChatQueuedEditDraft(
+ { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'old' }] },
+ 'bottom'
+ )
+ for (let i = 0; i < count; i++) {
+ nativeChatPastedTextOperations.set(`other-${i}`, {
+ get scopeKey() {
+ unrelatedReads++
+ return `other-${i}`
+ },
+ text: 'other'
+ })
+ }
+ const operation = { scopeKey: edit.scopeKey, text: 'own' }
+ nativeChatPastedTextOperations.set(ownId, operation)
+ updateNativeChatComposerDraft(
+ edit.scopeKey,
+ { text: nativeChatPastedTextToken(ownId), images: [] },
+ 'immediate'
+ )
+ handBackNativeChatQueuedEdit(edit)
+ expect(operation.scopeKey).toBe('bottom')
+ expect(unrelatedReads).toBe(0)
+ discardNativeChatQueuedEdit(edit)
+ nativeChatPastedTextOperations.clear()
+ }
+)
+
+import { queuedEditPublicationKey } from '../../../../shared/queued-message-edit-publication'
+it.each([10, 100])(
+ 'bounds a semantic publication lookup to three linear ID passes and one body at %i cards',
+ (count) => {
+ let ids = 0,
+ bodies = 0
+ const messages = Array.from({ length: count }, (_, index) => ({
+ get messageId() {
+ ids++
+ return `message-${index}`
+ },
+ position: index,
+ state: 'waiting' as const,
+ get body() {
+ bodies++
+ if (index !== count - 1) {
+ throw new Error('unrelated body read')
+ }
+ return {
+ kind: 'message' as const,
+ role: 'user' as const,
+ blocks: [{ type: 'text' as const, text: 'target' }]
+ }
+ }
+ }))
+ const submissions = Array.from({ length: count }, (_, index) => ({
+ clientMessageId: `submission-${index}`,
+ get queuedMessageId() {
+ ids++
+ return `message-${index}`
+ },
+ payloadFingerprint: 'proof',
+ fence: index,
+ dispatchState: 'pending' as const,
+ providerItemId: null,
+ reason: null,
+ submittedAt: 1,
+ resolvedAt: null
+ }))
+ queuedEditPublicationKey(
+ { messages, cards: messages, submissions },
+ 'session',
+ `message-${count - 1}`
+ )
+ expect(ids).toBe(3 * count)
+ expect(bodies).toBe(1)
+ }
+)
diff --git a/src/renderer/src/components/native-chat/inline-deferred-input.test.tsx b/src/renderer/src/components/native-chat/inline-deferred-input.test.tsx
new file mode 100644
index 000000000000..cfca9a315e7d
--- /dev/null
+++ b/src/renderer/src/components/native-chat/inline-deferred-input.test.tsx
@@ -0,0 +1,288 @@
+// @vitest-environment happy-dom
+import {
+ harness,
+ begin,
+ script,
+ requestedFingerprint,
+ SCOPE,
+ message
+} from './structured-queued-edit.test-fixture'
+import { useLayoutEffect, useRef } from 'react'
+import { act, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, expect, it, vi } from 'vitest'
+import { NativeChatPromptEditor } from './NativeChatPromptEditor'
+import { useNativeChatDraft } from './use-native-chat-draft'
+import { useNativeChatComposerHandle } from './use-native-chat-composer-handle'
+import type { NativeChatComposerInput } from './native-chat-composer-input'
+import type {
+ NativeChatComposerHandle,
+ NativeChatStructuredComposerTransport
+} from './native-chat-composer-types'
+import { registerNativeChatComposerDestination } from './native-chat-composer-destination'
+import {
+ readNativeChatComposerDraft,
+ updateNativeChatComposerDraft
+} from './native-chat-composer-draft-store'
+import { promptSkillRefs, promptText } from './native-chat-prompt-document'
+import { changePrompt, promptEditor } from './native-chat-prompt-editor.test-support'
+import { dispatchNativeChatStructuredComposerText } from './native-chat-structured-composer-dispatch'
+import { structuredAgentSessionSendBody } from '../../../../shared/structured-agent-session-send-mutation'
+import { resolveNativeChatPastedText } from './native-chat-composer-pasted-text-references'
+import {
+ addNativeChatPendingAttachment,
+ settleNativeChatPendingAttachmentReferences,
+ clearNativeChatPendingAttachmentsForTests
+} from './native-chat-pending-attachment-cache'
+import { nativeChatPastedTextOperations } from './native-chat-pasted-text-reference'
+
+const clipboard = vi.hoisted(() => ({ text: vi.fn(), file: vi.fn() }))
+vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
+const handles = new Map()
+function Field({ scope, disabled = false }: { scope: string; disabled?: boolean }) {
+ const input = useRef(null)
+ const handle = useRef(null)
+ const root = useRef(null)
+ const draft = useNativeChatDraft(scope, () => false)
+ useLayoutEffect(() => {
+ if (input.current && input.current.value !== draft.draft) {
+ input.current.value = draft.draft
+ }
+ }, [draft.draft])
+ useNativeChatComposerHandle(handle, {
+ textareaRef: input,
+ isComposing: () => false,
+ setDraft: draft.setDraft,
+ setCaret: () => {},
+ setActiveSuggestion: () => {},
+ attachmentScopeKey: scope,
+ targetKey: scope,
+ disabled,
+ caret: draft.draft.length,
+ resolveAttachmentOwner: () => ({ kind: 'local' }),
+ attachResolvedPaths: () => {},
+ attachCopiedFiles: () => {},
+ captureCopiedFiles: () => async () => {},
+ beginPendingImageAttachment: () => null,
+ resolvePendingImageAttachment: () => {},
+ dropPendingImageAttachment: () => {},
+ setNotice: () => {},
+ pastesInline: () => false,
+ pastedTextEditor: () => input.current
+ })
+ useLayoutEffect(() => {
+ handles.set(scope, handle.current!)
+ const unregister = registerNativeChatComposerDestination(root.current!, handle, true, scope)
+ return () => {
+ handles.delete(scope)
+ unregister()
+ }
+ }, [scope])
+ return (
+
+ draft.setDraft(field.value)}
+ onSelect={() => {}}
+ />
+
+ )
+}
+function send(scope = SCOPE) {
+ const draft = readNativeChatComposerDraft(scope)
+ return structuredAgentSessionSendBody(
+ resolveNativeChatPastedText(scope, draft.text),
+ draft.images.map((image) => ({ ...image, previewUri: image.path })),
+ promptSkillRefs(draft.document)
+ )
+}
+function deferred() {
+ let resolve!: (value: T) => void
+ const promise = new Promise((accept) => {
+ resolve = accept
+ })
+ return { promise, resolve }
+}
+beforeEach(() => {
+ clipboard.text.mockReset()
+ clipboard.file.mockReset().mockResolvedValue('/saved/paste.txt')
+ clearNativeChatPendingAttachmentsForTests()
+ nativeChatPastedTextOperations.clear()
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: {
+ ui: {
+ readClipboardText: clipboard.text,
+ readClipboardFilePaths: async () => [],
+ clipboardHasImage: async () => false,
+ savePastedTextAsFile: clipboard.file
+ }
+ }
+ })
+})
+it.each(['clean', 'success', 'cancel', 'no-field', 'no-field-oversize', 'long'] as const)(
+ 'native menu clipboard completion after %s uses the current destination and next serializer',
+ async (mode) => {
+ updateNativeChatComposerDraft(
+ SCOPE,
+ {
+ text:
+ mode === 'no-field'
+ ? '/clear'
+ : mode === 'no-field-oversize'
+ ? 'occupied'
+ : 'first\n\necho',
+ images: []
+ },
+ 'immediate'
+ )
+ const hook = harness()
+ await begin(hook)
+ const key = hook.result.current.editor!.draftScopeKey
+ const view = render(
+ <>
+
+
+ >
+ )
+ const read = deferred()
+ clipboard.text.mockReturnValueOnce(read.promise)
+ act(() => handles.get(key)!.pasteFromClipboard())
+ if (mode === 'success') {
+ act(() => hook.result.current.editor!.change('accepted'))
+ script.answer = async () => ({
+ kind: 'done',
+ value: {
+ status: 'updated',
+ messageId: message.messageId,
+ fingerprint: requestedFingerprint()
+ }
+ })
+ }
+ if (mode === 'cancel') {
+ act(() => hook.result.current.editor!.cancel())
+ } else {
+ act(() => hook.result.current.editor!.save())
+ await waitFor(() => expect(hook.result.current.editor).toBeUndefined())
+ }
+ view.rerender()
+ const bottom = promptEditor(screen.getByRole('textbox'))
+ act(() => bottom.commands.setTextSelection(bottom.state.doc.content.size - 1))
+ if (mode.startsWith('no-field')) {
+ view.unmount()
+ }
+ const text =
+ mode === 'no-field-oversize'
+ ? 'echo\n'.repeat(60000)
+ : mode === 'long' || mode === 'no-field'
+ ? 'echo\n'.repeat(7000)
+ : 'echo\n\necho'
+ await act(async () => read.resolve(text))
+ expect(clipboard.text).toHaveBeenCalledOnce()
+ if (mode === 'long') {
+ expect(clipboard.file).toHaveBeenCalledWith({ text })
+ await waitFor(() =>
+ expect(send().blocks).toEqual([{ type: 'text', text: 'first\n\necho @/saved/paste.txt' }])
+ )
+ expect(Object.values(readNativeChatComposerDraft(SCOPE).pastedTextFiles ?? {})).toEqual([
+ '/saved/paste.txt'
+ ])
+ } else if (mode === 'no-field-oversize') {
+ const transport: NativeChatStructuredComposerTransport = {
+ capturePresentation: () => () => true,
+ send: vi.fn(() => true),
+ dispatchCommand: async () => ({ handled: false, accepted: false, error: null }),
+ optionsSurface: {
+ getSnapshot: () => [],
+ setOption: vi.fn(),
+ invokeAction: vi.fn(),
+ subscribe: () => () => {}
+ },
+ optionSnapshot: [],
+ onError: () => {},
+ runtime: 'local',
+ sessionId: 's',
+ runtimeEnvironmentId: null
+ }
+ const value = readNativeChatComposerDraft(SCOPE)
+ expect(value.text).toBe(`occupied ${text}`)
+ expect(await dispatchNativeChatStructuredComposerText(transport, value.text)).toMatchObject({
+ accepted: false
+ })
+ expect(transport.send).not.toHaveBeenCalled()
+ expect(readNativeChatComposerDraft(SCOPE)).toEqual(value)
+ } else if (mode === 'no-field') {
+ expect(clipboard.file).not.toHaveBeenCalled()
+ expect(send().blocks).toEqual([{ type: 'text', text: `/clear ${text.trimEnd()}` }])
+ } else {
+ expect(send().blocks).toEqual([
+ { type: 'text', text: mode === 'cancel' ? 'first\n\necho' : 'first\n\nechoecho\n\necho' }
+ ])
+ expect(promptText(bottom.state.doc)).toBe(readNativeChatComposerDraft(SCOPE).text)
+ }
+ }
+)
+it('admitted menu text while a Save disables the mounted field changes complete equality and remains inline', async () => {
+ const hook = harness()
+ await begin(hook)
+ const key = hook.result.current.editor!.draftScopeKey
+ const view = render()
+ const read = deferred()
+ clipboard.text.mockReturnValueOnce(read.promise)
+ act(() => handles.get(key)!.pasteFromClipboard())
+ act(() => hook.result.current.editor!.change('accepted'))
+ const reply = deferred>>()
+ script.answer = () => reply.promise
+ act(() => hook.result.current.editor!.save())
+ const fingerprint = requestedFingerprint()
+ view.rerender()
+ await act(async () => read.resolve('echo'))
+ expect(readNativeChatComposerDraft(key).text).toBe('accepted echo')
+ await act(async () =>
+ reply.resolve({ kind: 'done', value: { status: 'updated', messageId: 'card', fingerprint } })
+ )
+ expect(hook.result.current.editor).toMatchObject({ text: 'accepted echo', saving: false })
+ expect(send().blocks).toEqual([])
+})
+it.each(['/clear', '@/same.txt'] as const)(
+ 'transferred file completion over %s uses ordinary input once per operation and next Send',
+ async (before) => {
+ updateNativeChatComposerDraft(SCOPE, { text: before, images: [] }, 'immediate')
+ const hook = harness()
+ await begin(hook)
+ const key = hook.result.current.editor!.draftScopeKey
+ const view = render(
+ <>
+
+
+ >
+ )
+ act(() =>
+ addNativeChatPendingAttachment(key, { id: 'file-operation', path: '', pending: true })
+ )
+ // A clean actual departure transfers the captured operation without copying the seed.
+ hook.rerender({ messages: [] })
+ expect(hook.result.current.editor).toBeUndefined()
+ view.rerender()
+ changePrompt(screen.getByRole('textbox'), before)
+ const editor = promptEditor(screen.getByRole('textbox'))
+ act(() => editor.commands.setTextSelection(editor.state.doc.content.size - 1))
+ act(() =>
+ expect(
+ settleNativeChatPendingAttachmentReferences(key, [
+ { id: 'file-operation', path: '/same.txt' }
+ ])
+ ).toBe(true)
+ )
+ expect(
+ settleNativeChatPendingAttachmentReferences(key, [
+ { id: 'file-operation', path: '/same.txt' }
+ ])
+ ).toBe(false)
+ expect(send().blocks).toEqual([{ type: 'text', text: `${before}@/same.txt` }])
+ expect(promptText(editor.state.doc)).toBe(`${before}@/same.txt`)
+ }
+)
diff --git a/src/renderer/src/components/native-chat/inline-queued-edit-lease.test.tsx b/src/renderer/src/components/native-chat/inline-queued-edit-lease.test.tsx
new file mode 100644
index 000000000000..7b1a8ce9a450
--- /dev/null
+++ b/src/renderer/src/components/native-chat/inline-queued-edit-lease.test.tsx
@@ -0,0 +1,325 @@
+// @vitest-environment happy-dom
+import {
+ script,
+ rpc,
+ HELD,
+ SESSION,
+ SCOPE,
+ message,
+ harness,
+ begin,
+ requestedFingerprint,
+ holdCalls,
+ type Outcome
+} from './structured-queued-edit.test-fixture'
+import { act, waitFor } from '@testing-library/react'
+import { beforeAll, describe, expect, it, vi } from 'vitest'
+import { toast } from 'sonner'
+import type { QueuedEditHostFixtureModule } from '../../../../shared/queued-edit-host.test-fixture'
+import type { AgentSessionQueuedMessageEditHoldResult } from '../../../../shared/agent-session-wire'
+import { readNativeChatDraftCache, writeNativeChatDraftCache } from './native-chat-draft-cache'
+
+let queuedEditHost: QueuedEditHostFixtureModule['queuedEditHost']
+beforeAll(async () => {
+ const fixtures = await vi.importActual(
+ '../../../../main/native-chat/agent-session-journal/queued-edit-host.test-fixture'
+ )
+ queuedEditHost = fixtures.queuedEditHost
+})
+
+vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
+function deferred() {
+ let resolve!: (value: T) => void
+ const promise = new Promise((accept) => {
+ resolve = accept
+ })
+ return { promise, resolve }
+}
+
+describe('current desktop reacquisition and Save share the outcome owner', () => {
+ it.each([
+ 'updated',
+ 'unchanged',
+ 'newer',
+ 'fresh-loss',
+ 'gone',
+ 'refused',
+ 'unknown',
+ 'late-unknown',
+ 'depart',
+ 'depart-equal'
+ ] as const)('%s settles a real host decision before current lease loss', async (mode) => {
+ const host = queuedEditHost(SESSION, message)
+ const hook = harness()
+ await begin(hook)
+ const loss = deferred()
+ const freshLoss = deferred()
+ let expected = ''
+ let editId = ''
+ let acquires = 0
+ rpc.mockImplementation(async (_target, _method, params) => {
+ if (params.action === 'release') {
+ host.leases.release({ callerKey: 'client', editId: params.editId }, params.messageId)
+ return { status: 'released' }
+ }
+ if (params.action !== 'acquire') {
+ return HELD
+ }
+ acquires++
+ expected = params.expectedBodyFingerprint ?? ''
+ editId = params.editId
+ return acquires === 1
+ ? loss.promise
+ : mode === 'fresh-loss'
+ ? freshLoss.promise
+ : host.acquire(editId, expected)
+ })
+ // Desktop initial acquisition is read-only; reach its real unblocked re-acquisition.
+ const first = deferred()
+ script.answer = () => first.promise
+ act(() => {
+ hook.result.current.editor!.change('A')
+ hook.result.current.editor!.save()
+ })
+ const fpA = requestedFingerprint()
+ host.update({ messageId: 'card', expectedBodyFingerprint: host.row!.fingerprint, text: 'A' })
+ const scope = hook.result.current.editor!.draftScopeKey
+ const { insertNativeChatComposerDraftText } =
+ await import('./native-chat-composer-draft-insertion')
+ act(() => insertNativeChatComposerDraftText(scope, 'B'))
+ await act(async () =>
+ first.resolve({
+ kind: 'done',
+ value: { status: 'updated', messageId: 'card', fingerprint: fpA }
+ })
+ )
+ expect(hook.result.current.editor).toMatchObject({ text: 'A B', acquiring: false })
+ writeNativeChatDraftCache(SCOPE, 'occupied')
+ const reply = deferred()
+ script.answer = () => reply.promise
+ if (mode === 'unchanged') {
+ host.update({
+ messageId: 'card',
+ expectedBodyFingerprint: host.row!.fingerprint,
+ text: 'A B'
+ })
+ }
+ act(() => hook.result.current.editor!.save())
+ const decision = host.update({
+ messageId: 'card',
+ expectedBodyFingerprint: expected,
+ text: 'A B'
+ })
+ expect(decision.status).toBe(mode === 'unchanged' ? 'unchanged' : 'updated')
+ if (mode === 'newer' || mode === 'fresh-loss' || mode === 'gone' || mode === 'depart') {
+ act(() => insertNativeChatComposerDraftText(scope, 'C'))
+ }
+ if (mode === 'gone') {
+ host.depart()
+ }
+ const negative = host.acquire(editId, expected)
+ expect(negative.status).toBe(mode === 'gone' ? 'gone' : 'changed')
+ if (mode === 'depart' || mode === 'depart-equal') {
+ hook.unmount()
+ }
+ if (mode !== 'late-unknown') {
+ await act(async () => loss.resolve(negative))
+ if (mode !== 'depart' && mode !== 'depart-equal') {
+ expect(hook.result.current.editor?.saving).toBe(true)
+ }
+ expect(readNativeChatDraftCache(SCOPE)).toBe('occupied')
+ expect(toast.error).not.toHaveBeenCalled()
+ }
+ const result: Outcome =
+ mode === 'unknown' || mode === 'late-unknown'
+ ? { kind: 'not-done', notice: 'unknown', failure: { kind: 'unconfirmed' } }
+ : mode === 'refused'
+ ? { kind: 'done', value: { status: 'not-editable', messageId: 'card' } }
+ : { kind: 'done', value: decision }
+ await act(async () => reply.resolve(result))
+ if (mode === 'late-unknown') {
+ expect(hook.result.current.editor).toMatchObject({ saving: false })
+ await act(async () => loss.resolve(negative))
+ }
+ if (mode === 'newer' || mode === 'fresh-loss') {
+ expect(hook.result.current.editor).toMatchObject({ text: 'A B C', saving: false })
+ expect(readNativeChatDraftCache(SCOPE)).toBe('occupied')
+ expect(acquires).toBe(2)
+ if (mode === 'newer') {
+ expect(host.leases.heldIds()).toEqual(new Set(['card']))
+ }
+ host.depart()
+ if (mode === 'fresh-loss') {
+ await act(async () => freshLoss.resolve(host.acquire(editId, expected)))
+ expect(hook.result.current.editor).toBeUndefined()
+ }
+ // A fresh negative is a fact about B, not the retired A mismatch.
+ hook.rerender({ messages: [] })
+ await waitFor(() => expect(hook.result.current.editor).toBeUndefined())
+ expect(readNativeChatDraftCache(SCOPE)).toBe('occupied\n\nA B C')
+ } else if (
+ mode === 'gone' ||
+ mode === 'unknown' ||
+ mode === 'late-unknown' ||
+ mode === 'refused' ||
+ mode === 'depart'
+ ) {
+ if (mode !== 'depart') {
+ expect(hook.result.current.editor).toBeUndefined()
+ }
+ expect(readNativeChatDraftCache(SCOPE)).toBe(
+ `occupied\n\nA B${mode === 'gone' || mode === 'depart' ? ' C' : ''}`
+ )
+ expect(toast.error).toHaveBeenCalledOnce()
+ } else {
+ if (mode !== 'depart-equal') {
+ expect(hook.result.current.editor).toBeUndefined()
+ }
+ expect(readNativeChatDraftCache(SCOPE)).toBe('occupied')
+ expect(toast.error).not.toHaveBeenCalled()
+ }
+ host.leases.dispose()
+ })
+})
+
+it.each(['unknown-changed', 'unknown-gone', 'pending-proof', 'rebase-loss'] as const)(
+ '%s joins the timed renewal/reacquisition producer to the Save owner without publication',
+ async (mode) => {
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
+ const host = queuedEditHost(SESSION, message)
+ const actions: string[] = []
+ rpc.mockImplementation(async (_target, _method, params) => {
+ actions.push(params.action)
+ const key = { callerKey: 'client', editId: params.editId }
+ if (params.action === 'release') {
+ host.leases.release(key, params.messageId)
+ return { status: 'released' }
+ }
+ return params.action === 'acquire'
+ ? host.acquire(params.editId, params.expectedBodyFingerprint ?? '')
+ : host.leases.renew(key, params.messageId)
+ })
+ const hook = harness()
+ await begin(hook)
+ writeNativeChatDraftCache(SCOPE, 'occupied')
+ const reply = deferred()
+ script.answer = () => reply.promise
+ act(() => hook.result.current.editor!.change('B'))
+ act(() => hook.result.current.editor!.save())
+ const decision = host.update({
+ messageId: 'card',
+ expectedBodyFingerprint: host.row!.fingerprint,
+ text: 'B'
+ })
+ expect(decision.status).toBe('updated')
+ if (mode === 'rebase-loss') {
+ const { insertNativeChatComposerDraftText } =
+ await import('./native-chat-composer-draft-insertion')
+ act(() => insertNativeChatComposerDraftText(hook.result.current.editor!.draftScopeKey, 'C'))
+ }
+ if (mode !== 'pending-proof') {
+ await act(async () =>
+ reply.resolve(
+ mode === 'rebase-loss'
+ ? { kind: 'done', value: decision }
+ : { kind: 'not-done', notice: 'unknown', failure: { kind: 'unconfirmed' } }
+ )
+ )
+ expect(hook.result.current.editor).toMatchObject({
+ text: mode === 'rebase-loss' ? 'B C' : 'B',
+ saving: false
+ })
+ }
+ if (mode === 'unknown-gone') {
+ host.depart()
+ }
+ if (mode === 'rebase-loss') {
+ expect(host.leases.heldIds()).toEqual(new Set(['card']))
+ hook.rerender({ messages: [{ ...message, body: host.row!.body }] })
+ expect(
+ host.update({
+ messageId: 'card',
+ expectedBodyFingerprint: host.row!.fingerprint,
+ text: 'rival'
+ }).status
+ ).toBe('updated')
+ }
+ await act(() => vi.advanceTimersByTimeAsync(30_000))
+ if (mode === 'pending-proof') {
+ expect(hook.result.current.editor?.saving).toBe(true)
+ expect(readNativeChatDraftCache(SCOPE)).toBe('occupied')
+ expect(toast.error).not.toHaveBeenCalled()
+ await act(async () => reply.resolve({ kind: 'done', value: decision }))
+ }
+ expect(hook.result.current.editor).toBeUndefined()
+ expect(readNativeChatDraftCache(SCOPE)).toBe(
+ mode === 'pending-proof' ? 'occupied' : `occupied\n\n${mode === 'rebase-loss' ? 'B C' : 'B'}`
+ )
+ expect(toast.error).toHaveBeenCalledTimes(mode === 'pending-proof' ? 0 : 1)
+ expect(actions).toEqual(
+ mode === 'rebase-loss'
+ ? ['acquire', 'acquire', 'release', 'renew', 'acquire', 'release']
+ : ['acquire', 'renew', 'acquire', 'release']
+ )
+ await act(() => vi.advanceTimersByTimeAsync(120_000))
+ expect(holdCalls('acquire')).toHaveLength(mode === 'rebase-loss' ? 3 : 2)
+ hook.unmount()
+ host.leases.dispose()
+ expect(vi.getTimerCount()).toBe(0)
+ }
+)
+
+it.each(['gone', 'held'] as const)(
+ 'first acquire failure unlocks typing and its timed %s retry is observed',
+ async (status) => {
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const host = queuedEditHost(SESSION, message)
+ rpc
+ .mockImplementation(async (_target, _method, params) => {
+ if (params.action === 'release') {
+ return { status: 'released' }
+ }
+ return host.acquire(
+ params.editId,
+ params.action === 'acquire' ? (params.expectedBodyFingerprint ?? '') : ''
+ )
+ })
+ .mockRejectedValueOnce(new Error('offline'))
+ const hook = harness()
+ await begin(hook)
+ expect(hook.result.current.editor?.acquiring).toBe(false)
+ writeNativeChatDraftCache(SCOPE, 'occupied')
+ act(() => hook.result.current.editor!.change('B'))
+ if (status === 'gone') {
+ host.depart()
+ }
+ await act(() => vi.advanceTimersByTimeAsync(30_000))
+ expect(holdCalls('acquire')).toHaveLength(2)
+ if (status === 'gone') {
+ expect(hook.result.current.editor).toBeUndefined()
+ expect(readNativeChatDraftCache(SCOPE)).toBe('occupied\n\nB')
+ expect(toast.error).toHaveBeenCalledOnce()
+ } else {
+ expect(hook.result.current.editor).toMatchObject({ text: 'B', acquiring: false })
+ expect(readNativeChatDraftCache(SCOPE)).toBe('occupied')
+ act(() => hook.result.current.editor!.cancel())
+ }
+ hook.unmount()
+ host.leases.dispose()
+ warn.mockRestore()
+ }
+)
+
+it.each(['changed', 'gone', 'not-editable'] as const)(
+ 'initial desktop %s has one clean disposition and readiness cannot revive it',
+ async (status) => {
+ rpc.mockResolvedValueOnce({ status })
+ const hook = harness()
+ await begin(hook)
+ expect(hook.result.current.editor).toBeUndefined()
+ expect(readNativeChatDraftCache(SCOPE)).toBe('')
+ expect(toast.error).toHaveBeenCalledTimes(status === 'not-editable' ? 1 : 0)
+ expect(holdCalls('release')).toHaveLength(1)
+ }
+)
diff --git a/src/renderer/src/components/native-chat/inline-rich-draft-consumers.test.tsx b/src/renderer/src/components/native-chat/inline-rich-draft-consumers.test.tsx
new file mode 100644
index 000000000000..fe5bf7e55c47
--- /dev/null
+++ b/src/renderer/src/components/native-chat/inline-rich-draft-consumers.test.tsx
@@ -0,0 +1,803 @@
+// @vitest-environment happy-dom
+import { useLayoutEffect, useRef, useState, useImperativeHandle } from 'react'
+import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { NativeChatPromptEditor } from './NativeChatPromptEditor'
+import type { NativeChatComposerInput } from './native-chat-composer-input'
+import { changePrompt, promptEditor } from './native-chat-prompt-editor.test-support'
+import { useNativeChatDraft } from './use-native-chat-draft'
+import { promptText, promptSkillRefs } from './native-chat-prompt-document'
+import {
+ createNativeChatQueuedEditDraft,
+ handBackNativeChatQueuedEdit,
+ discardNativeChatQueuedEdit
+} from './native-chat-queued-edit-draft'
+import {
+ readNativeChatComposerDraft,
+ updateNativeChatComposerDraft,
+ clearNativeChatComposerDraftsForTests,
+ hydrateNativeChatComposerDrafts
+} from './native-chat-composer-draft-store'
+import {
+ createMemoryNativeChatComposerDraftStorage,
+ setNativeChatComposerDraftStorageForTests,
+ type StoredNativeChatComposerDraft
+} from './native-chat-composer-draft-storage'
+import {
+ replayNativeChatComposerDraftJournal,
+ snapshotNativeChatComposerDraftJournal
+} from './native-chat-composer-draft-journal'
+import { resolveNativeChatPastedText } from './native-chat-composer-pasted-text-references'
+import { nativeChatPastedTextToken } from './native-chat-pasted-text-reference'
+import {
+ structuredAgentSessionSendBody,
+ agentSessionSendBodyFingerprint
+} from '../../../../shared/structured-agent-session-send-mutation'
+
+const scope = 'agent-session:rich-return'
+let composing = false
+function Bottom() {
+ const input = useRef(null)
+ const draft = useNativeChatDraft(scope, () => composing)
+ useLayoutEffect(() => {
+ if (!composing && input.current && input.current.value !== draft.draft) {
+ input.current.value = draft.draft
+ }
+ }, [draft.draft])
+ const root = useCapturedPrompt(scope, input, draft.setDraft, false)
+ return (
+
+ draft.setDraft(field.value)}
+ onSelect={() => {}}
+ />
+
+ )
+}
+function returned(text = '$review') {
+ const edit = createNativeChatQueuedEditDraft(
+ {
+ kind: 'message',
+ role: 'user',
+ blocks: [{ type: 'text', text }],
+ skills: [{ name: 'review', path: '/returned/SKILL.md' }]
+ },
+ scope
+ )
+ updateNativeChatComposerDraft(
+ edit.scopeKey,
+ {
+ text: `${text} changed`,
+ document: {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ { type: 'nativeChatSkill', attrs: { token: '$review', path: '/returned/SKILL.md' } },
+ { type: 'text', text: ' changed' }
+ ]
+ }
+ ]
+ }
+ },
+ 'immediate'
+ )
+ return edit
+}
+function nextSend() {
+ const draft = readNativeChatComposerDraft(scope)
+ return structuredAgentSessionSendBody(
+ resolveNativeChatPastedText(scope, draft.text),
+ draft.images.map((image) => ({ ...image, previewUri: image.path })),
+ promptSkillRefs(draft.document)
+ )
+}
+beforeEach(async () => {
+ composing = false
+ leaseRpc
+ .mockReset()
+ .mockImplementation(async (_target, _method, params) =>
+ params.action === 'release'
+ ? { status: 'released' }
+ : { status: 'held', fingerprint: 'fp', leaseDurationMs: 120000, remainingMs: 120000 }
+ )
+ saveRpc.mockReset()
+ localStorage.clear()
+ clearNativeChatComposerDraftsForTests()
+ setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage())
+ await hydrateNativeChatComposerDrafts()
+})
+afterEach(() => {
+ cleanup()
+ clearNativeChatComposerDraftsForTests()
+})
+
+it.each(['/clear', 'typed \n\n'])(
+ 'normalizes rich return over %j through the mounted editor and next Send',
+ (before) => {
+ updateNativeChatComposerDraft(scope, { text: before, images: [] }, 'immediate')
+ render()
+ const edit = returned()
+ act(() => {
+ handBackNativeChatQueuedEdit(edit)
+ discardNativeChatQueuedEdit(edit)
+ })
+ const editor = promptEditor(screen.getByRole('textbox'))
+ const expected = before === '/clear' ? '$review changed' : 'typed\n\n$review changed'
+ expect(promptText(editor.state.doc)).toBe(expected)
+ act(() => editor.commands.insertContentAt(editor.state.doc.content.size - 1, '!'))
+ expect(nextSend()).toMatchObject({
+ blocks: [{ type: 'text', text: `${expected}!` }],
+ skills: [{ name: 'review', path: '/returned/SKILL.md' }]
+ })
+ }
+)
+
+it('keeps equal visible skill tokens with distinct selected paths through return and Send', () => {
+ const bottom = createNativeChatQueuedEditDraft(
+ {
+ kind: 'message',
+ role: 'user',
+ blocks: [{ type: 'text', text: '$review changed' }],
+ skills: [{ name: 'review', path: '/bottom/SKILL.md' }]
+ },
+ 'unused'
+ )
+ updateNativeChatComposerDraft(scope, readNativeChatComposerDraft(bottom.scopeKey), 'immediate')
+ render()
+ const edit = returned()
+ act(() => {
+ handBackNativeChatQueuedEdit(edit)
+ discardNativeChatQueuedEdit(edit)
+ })
+ expect(promptText(promptEditor(screen.getByRole('textbox')).state.doc)).toBe(
+ '$review changed\n\n$review changed'
+ )
+ expect(nextSend().skills).toEqual([
+ { name: 'review', path: '/bottom/SKILL.md' },
+ { name: 'review', path: '/returned/SKILL.md' }
+ ])
+ discardNativeChatQueuedEdit(bottom)
+})
+
+it('keeps a rich return through an actual composing editor transaction and next Send', () => {
+ updateNativeChatComposerDraft(scope, { text: 'typed', images: [] }, 'immediate')
+ render()
+ const editor = promptEditor(screen.getByRole('textbox'))
+ composing = true
+ const edit = returned()
+ act(() => {
+ handBackNativeChatQueuedEdit(edit)
+ discardNativeChatQueuedEdit(edit)
+ })
+ expect(promptText(editor.state.doc)).toBe('typed')
+ act(() =>
+ editor.view.dispatch(editor.state.tr.insertText('가', editor.state.doc.content.size - 1))
+ )
+ expect(readNativeChatComposerDraft(scope).text).toBe('typed가\n\n$review changed')
+ expect(nextSend().skills).toEqual([{ name: 'review', path: '/returned/SKILL.md' }])
+})
+
+it('replays a full rich addition into existing content, renders it and sends resolved files/images/skills', () => {
+ const edit = returned()
+ const paste = 'd851d135-98ac-44d1-b122-caf190002345'
+ const draft = readNativeChatComposerDraft(edit.scopeKey)
+ updateNativeChatComposerDraft(
+ edit.scopeKey,
+ {
+ text: `${draft.text}\n${nativeChatPastedTextToken(paste)}`,
+ document: {
+ ...draft.document,
+ content: [
+ ...(draft.document?.content ?? []),
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'nativeChatFileReference',
+ attrs: { token: nativeChatPastedTextToken(paste), path: '' }
+ }
+ ]
+ }
+ ]
+ },
+ pastedTextFiles: { [paste]: '/remote/paste.txt' },
+ images: [{ id: 'new', path: '/new.png' }]
+ },
+ 'immediate'
+ )
+ handBackNativeChatQueuedEdit(edit)
+ discardNativeChatQueuedEdit(edit)
+ // Simulate another renderer run reading the actual addition journal before storage confirmation.
+ const raw = localStorage.getItem('orca:nativeChatComposerDraftJournal:v1')!
+ localStorage.setItem(
+ 'orca:nativeChatComposerDraftJournal:v1',
+ raw.replaceAll(/"run":"[^"]+"/g, '"run":"previous-run"')
+ )
+ const loaded = new Map([
+ [scope, { text: 'existing', images: [], savedAt: 0 }]
+ ])
+ replayNativeChatComposerDraftJournal(loaded, snapshotNativeChatComposerDraftJournal())
+ updateNativeChatComposerDraft(scope, loaded.get(scope)!, 'immediate')
+ render()
+ expect(promptSkillRefs(promptEditor(screen.getByRole('textbox')).getJSON())).toEqual([
+ { name: 'review', path: '/returned/SKILL.md' }
+ ])
+ expect(nextSend()).toMatchObject({
+ blocks: [
+ { type: 'text', text: expect.stringContaining('@/remote/paste.txt') },
+ { type: 'image-ref', path: '/new.png' }
+ ],
+ skills: [{ name: 'review', path: '/returned/SKILL.md' }]
+ })
+})
+
+// Joined replacement boundary: real edit owner, picker, mounted rich editor and wire serializer.
+const leaseRpc = vi.hoisted(() =>
+ vi.fn(
+ async (
+ _target: unknown,
+ _method: string,
+ _params: AgentSessionQueuedMessageEditHoldParams
+ ): Promise => ({
+ status: 'held',
+ fingerprint: 'fp',
+ leaseDurationMs: 120000,
+ remainingMs: 120000
+ })
+ )
+)
+vi.mock('@/runtime/structured-agent-session-client', () => ({
+ callStructuredAgentSession: leaseRpc
+}))
+vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
+vi.mock('./use-native-chat-skills', () => ({
+ useNativeChatSkills: () => ({ status: 'ready', skills: [], retry: () => {} })
+}))
+import { useStructuredAgentSessionQueuedEdit } from './use-structured-agent-session-queued-edit'
+import { projectQueuedMessageCards } from '../../../../shared/structured-agent-session-queued-cards'
+import { useNativeChatPickerState } from './use-native-chat-picker-state'
+import type {
+ AgentSessionQueuedMessage,
+ AgentSessionQueuedMessageEditHoldParams,
+ AgentSessionQueuedMessageEditHoldResult
+} from '../../../../shared/agent-session-wire'
+import { queuedMessageWithEdit } from '../../../../shared/queued-message-edit'
+import type { StructuredAgentSessionWrite } from './use-structured-agent-session-mutate'
+let controller: ReturnType
+let picker: ReturnType
+const saveRpc = vi.fn()
+const joinedWrite: StructuredAgentSessionWrite = saveRpc
+function InlineField({ scopeKey }: { scopeKey: string }) {
+ const input = useRef(null)
+ const draft = useNativeChatDraft(scopeKey, () => false)
+ const [caret, setCaret] = useState(draft.draft.length)
+ const [, setActiveSuggestion] = useState(0)
+ picker = useNativeChatPickerState({
+ agent: 'codex',
+ terminalTabId: 'inline',
+ draftScopeKey: scopeKey,
+ draft: draft.draft,
+ caret,
+ agentCommands: [],
+ recalledFromHistory: false,
+ disabled: false,
+ isComposing: () => false,
+ sessionSkills: [{ name: 'review', path: '/B/SKILL.md' }],
+ textareaRef: input,
+ setDraft: draft.setDraft,
+ setCaret,
+ setActiveSuggestion
+ })
+ const root = useCapturedPrompt(scopeKey, input, draft.setDraft, true)
+ return (
+
+ {
+ draft.setDraft(field.value)
+ setCaret(field.value.length)
+ }}
+ onSelect={(field) => setCaret(field.selectionStart ?? field.value.length)}
+ />
+
+ )
+}
+function JoinedEdit({ messages }: { messages: AgentSessionQueuedMessage[] }) {
+ controller = useStructuredAgentSessionQueuedEdit({
+ transport: {
+ target: { kind: 'local' },
+ sessionId: 'joined',
+ capable: true,
+ imagesCapable: true,
+ skillsCapable: true,
+ write: joinedWrite
+ },
+ messages,
+ cards: projectQueuedMessageCards(messages, [], {}),
+ submissions: [],
+ composerScopeKey: scope,
+ promptOpen: true
+ })
+ return controller.editor ? (
+
+ ) : null
+}
+it('retains re-picked rich identity after unknown proof and saves the whole replacement against its proven base', async () => {
+ leaseRpc.mockClear()
+ const original: AgentSessionQueuedMessage = {
+ messageId: 'joined-card',
+ position: 1,
+ state: 'waiting',
+ body: {
+ kind: 'message',
+ role: 'user',
+ blocks: [{ type: 'text', text: '$review original' }],
+ skills: [{ name: 'review', path: '/A/SKILL.md' }]
+ }
+ }
+ updateNativeChatComposerDraft(scope, { text: 'occupied', images: [] }, 'immediate')
+ const view = render(
+ <>
+
+
+ >
+ )
+ await act(() => controller.begin(original.messageId))
+ changePrompt(screen.getByRole('textbox', { name: 'Inline' }), '$review')
+ const inlineScope = controller.editor!.draftScopeKey
+ const captured = queuedMessageWithEdit(original.body, {
+ text: '$review',
+ skills: [{ name: 'review', path: '/A/SKILL.md' }],
+ images: []
+ })
+ if (!captured.ok) {
+ throw new Error(captured.reason)
+ }
+ // setContent intentionally replaced the chip; restore selected A through the existing value store before Save.
+ act(() =>
+ updateNativeChatComposerDraft(
+ inlineScope,
+ {
+ document: {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ { type: 'nativeChatSkill', attrs: { token: '$review', path: '/A/SKILL.md' } }
+ ]
+ }
+ ]
+ }
+ },
+ 'immediate'
+ )
+ )
+ saveRpc.mockResolvedValueOnce({
+ kind: 'not-done',
+ notice: 'Unknown',
+ failure: { kind: 'unconfirmed' }
+ })
+ act(() => controller.editor!.save())
+ await waitFor(() => expect(controller.editor!.saving).toBe(false))
+ changePrompt(screen.getByRole('textbox', { name: 'Inline' }), '$rev')
+ const item =
+ picker.autocomplete.mode === 'slash'
+ ? picker.autocomplete.items.find((row) => row.kind === 'skill' && row.path === '/B/SKILL.md')
+ : undefined
+ expect(item).toBeDefined()
+ act(() => picker.completeItem(item!))
+ const input = promptEditor(screen.getByRole('textbox', { name: 'Inline' }))
+ act(() => input.commands.insertContentAt(input.state.doc.content.size - 1, 'suffix'))
+ const paste = 'b851d135-98ac-44d1-b122-caf190002345'
+ act(() =>
+ updateNativeChatComposerDraft(
+ inlineScope,
+ {
+ images: [{ id: 'new-local', path: '/new.png', connectionId: 'host-a' }],
+ pastedTextFiles: { [paste]: '/paste.txt' }
+ },
+ 'immediate'
+ )
+ )
+ const beforeProof = readNativeChatComposerDraft(inlineScope)
+ view.rerender(
+ <>
+
+
+ >
+ )
+ expect(controller.editor).toMatchObject({
+ draftScopeKey: inlineScope,
+ acquiring: false,
+ saving: false
+ })
+ expect(readNativeChatComposerDraft(inlineScope)).toEqual(beforeProof)
+ expect(readNativeChatComposerDraft(scope).text).toBe('occupied')
+ expect(promptSkillRefs(input.getJSON())).toEqual([{ name: 'review', path: '/B/SKILL.md' }])
+ const acquire = leaseRpc.mock.calls
+ .map((call) => call[2])
+ .filter((params) => params?.action === 'acquire')
+ expect(acquire).toHaveLength(2)
+ expect(acquire[1]).toMatchObject({
+ expectedBodyFingerprint: agentSessionSendBodyFingerprint('joined', captured.body)
+ })
+ expect(acquire[1].editId).not.toBe(acquire[0].editId)
+ saveRpc.mockResolvedValueOnce({
+ kind: 'done',
+ value: {
+ status: 'updated',
+ messageId: original.messageId,
+ fingerprint: agentSessionSendBodyFingerprint(
+ 'joined',
+ structuredAgentSessionSendBody(
+ '$review suffix',
+ [{ path: '/new.png', previewUri: '/new.png' }],
+ [{ name: 'review', path: '/B/SKILL.md' }]
+ )
+ )
+ }
+ })
+ act(() => controller.editor!.save())
+ await waitFor(() => expect(controller.editor).toBeUndefined())
+ expect(saveRpc.mock.calls.at(-1)?.[2]).toMatchObject({
+ expectedBodyFingerprint: agentSessionSendBodyFingerprint('joined', captured.body),
+ text: '$review suffix',
+ skills: [{ name: 'review', path: '/B/SKILL.md' }],
+ images: [{ type: 'image-ref', path: '/new.png' }]
+ })
+ expect(nextSend().blocks).toEqual([{ type: 'text', text: 'occupied' }])
+})
+
+import { useNativeChatTypedInsertion } from './use-native-chat-typed-insertion'
+import { useNativeChatComposerEditSession } from './use-native-chat-composer-edit-session'
+import type { NativeChatComposerHandle } from './native-chat-composer-types'
+import { captureInsertionTarget, insertText } from '../dictation/dictation-insertion-target'
+function useCapturedPrompt(
+ key: string,
+ input: { current: NativeChatComposerInput | null },
+ setDraft: (text: string) => void,
+ editing: boolean
+) {
+ const root = useRef(null)
+ const handle = useRef(null)
+ const insertion = useNativeChatTypedInsertion({
+ textareaRef: input,
+ isComposing: () => composing,
+ setDraft,
+ setCaret: () => {},
+ setActiveSuggestion: () => {}
+ })
+ useImperativeHandle(handle, () => ({
+ ...insertion,
+ isComposing: () => composing,
+ handlePasteEvent: () => {},
+ pasteFromClipboard: () => {}
+ }))
+ useNativeChatComposerEditSession({
+ root,
+ handle,
+ draftScopeKey: key,
+ agent: 'codex',
+ editSession: editing ? { save: () => {}, cancel: () => {}, canSave: true } : undefined,
+ structuredTransport: undefined,
+ sendStructured: () => {},
+ ptyCommandRouting: {
+ agent: 'codex',
+ disabled: false,
+ isDispatchingSessionOption: false,
+ resolveTarget: () => null,
+ sessionOptionsSurface: null,
+ trackPendingSend: () => {},
+ setDraft: () => {},
+ setCaret: () => {},
+ setActiveSuggestion: () => {},
+ clearSkillOrigin: () => {},
+ clearImageAttachments: () => {},
+ setNotice: () => {}
+ },
+ setNotice: () => {},
+ disabled: false,
+ sendHeld: false,
+ sendMessage: () => {},
+ completeItem: () => {}
+ })
+ return root
+}
+it.each(['mounted', 'success', 'unmounted', 'ime', 'cancel'] as const)(
+ 'captured late repeated dictation uses actual rich input/store insertion after %s',
+ async (mode) => {
+ updateNativeChatComposerDraft(scope, { text: 'first\n\necho', images: [] }, 'immediate')
+ const original: AgentSessionQueuedMessage = {
+ messageId: 'voice',
+ position: 1,
+ state: 'waiting',
+ body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'seed' }] }
+ }
+ const view = render(
+ <>
+
+
+ >
+ )
+ await act(() => controller.begin('voice'))
+ screen.getByRole('textbox', { name: 'Inline' }).focus()
+ const captured = captureInsertionTarget()
+ expect(captured?.kind).toBe('composer')
+ if (!captured) {
+ throw new Error('missing captured voice')
+ }
+ if (mode === 'success') {
+ changePrompt(screen.getByRole('textbox', { name: 'Inline' }), 'accepted')
+ saveRpc.mockResolvedValueOnce({
+ kind: 'done',
+ value: {
+ status: 'updated',
+ messageId: 'voice',
+ fingerprint: agentSessionSendBodyFingerprint(
+ 'joined',
+ structuredAgentSessionSendBody('accepted', [])
+ )
+ }
+ })
+ }
+ if (mode === 'cancel') {
+ act(() => controller.editor!.cancel())
+ } else {
+ act(() => controller.editor!.save())
+ await waitFor(() => expect(controller.editor).toBeUndefined())
+ }
+ if (mode === 'unmounted') {
+ view.unmount()
+ }
+ if (mode === 'ime') {
+ composing = true
+ }
+ if (mode === 'mounted' || mode === 'success') {
+ const bottom = promptEditor(screen.getByRole('textbox', { name: 'Bottom' }))
+ act(() => bottom.commands.setTextSelection(bottom.state.doc.content.size - 1))
+ }
+ act(() => insertText('echo', captured))
+ if (mode === 'ime') {
+ const bottom = promptEditor(screen.getByRole('textbox', { name: 'Bottom' }))
+ expect(promptText(bottom.state.doc)).toBe('first\n\necho')
+ act(() =>
+ bottom.view.dispatch(bottom.state.tr.insertText('가', bottom.state.doc.content.size - 1))
+ )
+ expect(nextSend().blocks).toEqual([{ type: 'text', text: 'first\n\necho가 echo' }])
+ } else {
+ expect(nextSend().blocks).toEqual([
+ {
+ type: 'text',
+ text:
+ mode === 'cancel'
+ ? 'first\n\necho'
+ : mode === 'mounted' || mode === 'success'
+ ? 'first\n\nechoecho'
+ : 'first\n\necho echo'
+ }
+ ])
+ }
+ }
+)
+
+it('returns the complete rebased rich replacement once, excluding exact accepted images but retaining a reused ID with changed payload', async () => {
+ const original: AgentSessionQueuedMessage = {
+ messageId: 'images',
+ position: 1,
+ state: 'waiting',
+ body: {
+ kind: 'message',
+ role: 'user',
+ blocks: [
+ { type: 'text', text: '$review original' },
+ { type: 'image-ref', path: '/original.png', alt: 'original' }
+ ],
+ skills: [{ name: 'review', path: '/A/SKILL.md' }]
+ }
+ }
+ updateNativeChatComposerDraft(
+ scope,
+ { text: 'occupied', images: [{ id: 'bottom', path: '/bottom.png' }] },
+ 'immediate'
+ )
+ const view = render(
+ <>
+
+
+ >
+ )
+ await act(() => controller.begin('images'))
+ const key = controller.editor!.draftScopeKey
+ act(() =>
+ updateNativeChatComposerDraft(
+ key,
+ {
+ text: '$review submitted',
+ images: [
+ ...readNativeChatComposerDraft(key).images,
+ { id: 'uploaded', path: '/accepted.png', connectionId: 'remote' }
+ ]
+ },
+ 'immediate'
+ )
+ )
+ const fields = {
+ text: '$review submitted',
+ skills: [{ name: 'review', path: '/A/SKILL.md' }],
+ images: [
+ { type: 'image-ref' as const, path: '/original.png', alt: 'original' },
+ { type: 'image-ref' as const, path: '/accepted.png' }
+ ]
+ }
+ const accepted = queuedMessageWithEdit(original.body, fields)
+ if (!accepted.ok) {
+ throw new Error(accepted.reason)
+ }
+ saveRpc.mockResolvedValueOnce({
+ kind: 'not-done',
+ notice: 'Unknown',
+ failure: { kind: 'unconfirmed' }
+ })
+ act(() => controller.editor!.save())
+ await waitFor(() => expect(controller.editor!.saving).toBe(false))
+ changePrompt(screen.getByRole('textbox', { name: 'Inline' }), '$rev')
+ const row =
+ picker.autocomplete.mode === 'slash'
+ ? picker.autocomplete.items.find((entry) => entry.kind === 'skill')
+ : undefined
+ act(() => picker.completeItem(row!))
+ const editor = promptEditor(screen.getByRole('textbox', { name: 'Inline' }))
+ act(() => editor.commands.insertContentAt(editor.state.doc.content.size - 1, 'newer'))
+ view.rerender(
+ <>
+
+
+ >
+ )
+ expect(controller.editor?.draftScopeKey).toBe(key)
+ const paste = 'c851d135-98ac-44d1-b122-caf190002345'
+ act(() => {
+ editor.commands.insertContentAt(editor.state.doc.content.size - 1, [
+ { type: 'text', text: ' ' },
+ {
+ type: 'nativeChatFileReference',
+ attrs: { token: nativeChatPastedTextToken(paste), path: '' }
+ }
+ ])
+ })
+ act(() => {
+ updateNativeChatComposerDraft(
+ key,
+ {
+ pastedTextFiles: { [paste]: '/late-file.txt' },
+ images: [
+ ...readNativeChatComposerDraft(key).images.filter(({ id }) => id !== 'uploaded'),
+ { id: 'uploaded', path: '/changed.png', connectionId: 'other-host' },
+ { id: 'new', path: '/unsaved.png' }
+ ]
+ },
+ 'immediate'
+ )
+ })
+ view.rerender(
+ <>
+
+
+ >
+ )
+ expect(controller.editor).toBeUndefined()
+ expect(nextSend()).toMatchObject({
+ blocks: [
+ { type: 'text', text: expect.stringContaining('occupied\n\n$review newer @/late-file.txt') },
+ { type: 'image-ref', path: '/bottom.png' },
+ { type: 'image-ref', path: '/changed.png' },
+ { type: 'image-ref', path: '/unsaved.png' }
+ ],
+ skills: [{ name: 'review', path: '/B/SKILL.md' }]
+ })
+ const returned = readNativeChatComposerDraft(scope)
+ view.rerender(
+ <>
+
+
+ >
+ )
+ expect(readNativeChatComposerDraft(scope)).toEqual(returned)
+})
+
+it('replays captured ordinary insertion over late storage hydration without changing recovery-journal format', async () => {
+ clearNativeChatComposerDraftsForTests()
+ let load: ((drafts: Map) => void) | undefined
+ const storage = createMemoryNativeChatComposerDraftStorage()
+ storage.drafts.set(scope, { text: 'echo', images: [], savedAt: 1 })
+ setNativeChatComposerDraftStorageForTests({
+ ...storage,
+ loadAll: () =>
+ new Promise((resolve) => {
+ load = resolve
+ })
+ })
+ const loading = hydrateNativeChatComposerDrafts()
+ const original: AgentSessionQueuedMessage = {
+ messageId: 'hydrate-voice',
+ position: 1,
+ state: 'waiting',
+ body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'seed' }] }
+ }
+ const view = render()
+ await act(() => controller.begin(original.messageId))
+ screen.getByRole('textbox', { name: 'Inline' }).focus()
+ const target = captureInsertionTarget()
+ if (!target) {
+ throw new Error('missing captured target')
+ }
+ act(() => controller.editor!.save())
+ await waitFor(() => expect(controller.editor).toBeUndefined())
+ view.unmount()
+ act(() => insertText('echo', target))
+ await act(async () => {
+ load!(new Map([[scope, { text: 'echo', images: [], savedAt: 1 }]]))
+ await loading
+ })
+ render()
+ expect(nextSend().blocks).toEqual([{ type: 'text', text: 'echo echo' }])
+ expect(promptText(promptEditor(screen.getByRole('textbox')).state.doc)).toBe('echo echo')
+})
+
+it('a late obsolete reacquisition rejection cannot close a twice-rebased editor or gate its next Save', async () => {
+ const original: AgentSessionQueuedMessage = {
+ messageId: 'leases',
+ position: 1,
+ state: 'waiting',
+ body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'seed' }] }
+ }
+ const view = render()
+ await act(() => controller.begin(original.messageId))
+ let obsolete: ((answer: AgentSessionQueuedMessageEditHoldResult) => void) | undefined
+ leaseRpc.mockImplementationOnce((_target, _method, params) =>
+ params.action === 'acquire'
+ ? new Promise((resolve) => {
+ obsolete = resolve
+ })
+ : Promise.resolve({ status: 'released' })
+ )
+ saveRpc.mockResolvedValue({
+ kind: 'not-done',
+ notice: 'Unknown',
+ failure: { kind: 'unconfirmed' }
+ })
+ changePrompt(screen.getByRole('textbox'), 'A')
+ act(() => controller.editor!.save())
+ await waitFor(() => expect(controller.editor!.saving).toBe(false))
+ changePrompt(screen.getByRole('textbox'), 'B')
+ const a = { ...original, body: structuredAgentSessionSendBody('A', []) }
+ view.rerender()
+ expect(controller.editor).toMatchObject({ acquiring: false, canSave: true })
+ act(() => controller.editor!.save())
+ await waitFor(() => expect(controller.editor!.saving).toBe(false))
+ changePrompt(screen.getByRole('textbox'), 'C')
+ view.rerender(
+
+ )
+ expect(obsolete).toBeDefined()
+ await act(async () => obsolete!({ status: 'changed' }))
+ expect(controller.editor).toMatchObject({
+ text: 'C',
+ acquiring: false,
+ saving: false,
+ canSave: true
+ })
+ expect(readNativeChatComposerDraft(scope).text).toBe('')
+})
diff --git a/src/renderer/src/components/native-chat/native-chat-composer-attachment-contract.ts b/src/renderer/src/components/native-chat/native-chat-composer-attachment-contract.ts
new file mode 100644
index 000000000000..b6c4390da1b2
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-composer-attachment-contract.ts
@@ -0,0 +1,48 @@
+import type { RefObject } from 'react'
+import type { NativeChatComposerInput } from './native-chat-composer-input'
+import type { NativeChatResolvedTarget } from './native-chat-composer-target'
+import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
+import type { NativeChatResolvedPathOptions } from './native-chat-resolved-path-ownership'
+import type { NativeChatPendingAttachmentChips } from './native-chat-session-attachment-drop'
+export type UseNativeChatComposerAttachmentsArgs = {
+ attachmentScopeKey: string
+ /** False when the agent takes no image input; see `useNativeChatResolvedPathAttachments`. */
+ acceptsImages?: boolean
+ /** Structured chats only: each image gets a chip in the prompt where it was attached. */
+ imageReferences?: boolean
+ allowWithoutTarget?: boolean
+ caret: number
+ disabled: boolean
+ isComposing: () => boolean
+ resolveTarget: () => NativeChatResolvedTarget | null
+ textareaRef: RefObject
+ setCaret: (caret: number) => void
+ setDraft: (updater: (previous: string) => string) => void
+ setNotice: (notice: string | null) => void
+}
+
+export type NativeChatComposerAttachments = {
+ imageAttachments: NativeChatComposerImageAttachment[]
+ /** A long paste in this scope is still being saved as a file; Send waits for it. */
+ pastedTextSaving: boolean
+ attachResolvedPaths: (
+ paths: string[],
+ connectionId?: string | null,
+ options?: NativeChatResolvedPathOptions
+ ) => void
+ clearImageAttachments: () => void
+ flushPendingAttachments: () => void
+ removeImageAttachment: (id: string) => void
+ /** `serverOwned`: an image the paste saved on the remote target's own server, not a client file. */
+ beginPendingImageAttachment: (
+ previewUrl?: string,
+ pendingName?: string,
+ serverOwned?: boolean
+ ) => string | null
+ resolvePendingImageAttachment: NativeChatPendingAttachmentChips['resolve']
+ revealPendingImageAttachment: (id: string, previewUrl?: string) => void
+ dropPendingImageAttachment: (id: string) => boolean
+ pendingChips: NativeChatPendingAttachmentChips
+ /** The editor while it shows this scope; null once it went or shows another. */
+ scopeEditor: () => NativeChatComposerInput | null
+}
diff --git a/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx
index 3c2de0a9e823..1753e71071a6 100644
--- a/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx
+++ b/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx
@@ -6,12 +6,11 @@ import { createRef } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
-vi.mock('@/i18n/i18n', () => ({
- translate: (_key: string, fallback: string) => fallback
-}))
+import type * as NativeI18n from '@/i18n/i18n'
-vi.mock('./NativeChatComposerActions', () => ({
- NativeChatComposerActions: () =>
+vi.mock('@/i18n/i18n', async (original) => ({
+ ...(await original()),
+ translate: (_key: string, fallback: string) => fallback
}))
vi.mock('./NativeChatAutocompleteMenus', () => ({
@@ -24,6 +23,28 @@ import {
type NativeChatComposerFieldProps
} from './NativeChatComposerField'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import { useNativeChatDraft } from './use-native-chat-draft'
+import { useNativeChatDraftEvents } from './use-native-chat-draft-events'
+import { useNativeChatStructuredComposerSend } from './use-native-chat-structured-composer-send'
+import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types'
+import {
+ createNativeChatQueuedEditDraft,
+ handBackNativeChatQueuedEdit,
+ discardNativeChatQueuedEdit
+} from './native-chat-queued-edit-draft'
+import {
+ readNativeChatComposerDraft,
+ updateNativeChatComposerDraft,
+ clearNativeChatComposerDraftsForTests,
+ hydrateNativeChatComposerDrafts
+} from './native-chat-composer-draft-store'
+import {
+ createMemoryNativeChatComposerDraftStorage,
+ setNativeChatComposerDraftStorageForTests
+} from './native-chat-composer-draft-storage'
+import { promptSkillRefs } from './native-chat-prompt-document'
+import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer'
afterEach(() => cleanup())
@@ -31,7 +52,11 @@ type TestFieldProps = Omit
function TestField(props: TestFieldProps): React.JSX.Element {
const imeEnterGesture = useImeEnterGestureOwnership()
- return
+ return (
+
+
+
+ )
}
function fieldProps(overrides: Partial = {}): TestFieldProps {
@@ -86,6 +111,54 @@ describe('native chat composer drop ownership', () => {
})
})
+describe('queued edit placeholder ownership', () => {
+ it('names the edit while locally locked, keeping Cancel available and bottom copy unchanged', () => {
+ const cancel = vi.fn()
+ const props = fieldProps({
+ draftScopeKey: 'queued-edit:image-only',
+ editSession: { cancel },
+ imageAttachments: [
+ { id: 'retained-image', path: '/image.png', previewUrl: 'data:image/png;base64,AA==' }
+ ],
+ disabled: true,
+ canSend: false,
+ sendButtonDisabled: true,
+ attachDisabled: true,
+ dictationDisabled: true
+ })
+ const view = render()
+
+ expect(
+ screen.getByRole('textbox', { name: 'Edit message' }).getAttribute('contenteditable')
+ ).toBe('false')
+ expect(view.container.querySelector('[data-placeholder="Edit message"]')).not.toBeNull()
+ expect(screen.getByRole('button', { name: 'Save' }).hasAttribute('disabled')).toBe(true)
+ const cancelButton = screen.getByRole('button', { name: 'Cancel' })
+ expect(cancelButton.hasAttribute('disabled')).toBe(false)
+
+ view.rerender()
+ expect(
+ screen.getByRole('textbox', { name: 'Edit message' }).getAttribute('contenteditable')
+ ).toBe('true')
+ expect(screen.getByRole('button', { name: 'Save' }).hasAttribute('disabled')).toBe(false)
+ view.rerender()
+ expect(
+ screen.getByRole('textbox', { name: 'Edit message' }).getAttribute('contenteditable')
+ ).toBe('false')
+ expect(view.container.querySelector('[data-placeholder="Edit message"]')).not.toBeNull()
+ expect(screen.getByRole('button', { name: 'Save' }).hasAttribute('disabled')).toBe(true)
+ expect(cancelButton.hasAttribute('disabled')).toBe(false)
+ fireEvent.click(cancelButton)
+ expect(cancel).toHaveBeenCalledOnce()
+
+ view.rerender(
+
+ )
+ expect(screen.getByRole('textbox', { name: 'Input is held by another device.' })).toBeTruthy()
+ expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull()
+ })
+})
+
describe('native chat composer composition ownership', () => {
it('preserves the focused browser preedit through 120 stale streaming rerenders', () => {
const textareaRef = createRef()
@@ -318,3 +391,129 @@ describe('native chat composer composition ownership', () => {
)
})
})
+
+it('keeps an actual composing return through command acceptance, composition settlement and the next real Send', async () => {
+ clearNativeChatComposerDraftsForTests()
+ setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage())
+ await hydrateNativeChatComposerDrafts()
+ const scope = 'agent-session:composition-return'
+ updateNativeChatComposerDraft(scope, { text: '/goal ship it' }, 'immediate')
+ const reply = Promise.withResolvers()
+ const transport: NativeChatStructuredComposerTransport = {
+ capturePresentation: () => () => true,
+ send: vi.fn(() => true),
+ threadGoal: { setObjective: () => reply.promise },
+ dispatchCommand: (text) =>
+ dispatchStructuredAgentSessionComposerCommand(text, {
+ agent: 'codex',
+ snapshot: [],
+ invokeAction: async () => true,
+ setOption: async () => true,
+ setThreadGoalObjective: () => reply.promise
+ }),
+ optionsSurface: {
+ getSnapshot: () => [],
+ setOption: vi.fn(),
+ invokeAction: vi.fn(),
+ subscribe: () => () => {}
+ },
+ optionSnapshot: [],
+ onError: vi.fn(),
+ runtime: 'local',
+ sessionId: 'composition-return',
+ runtimeEnvironmentId: null
+ }
+ let send: ReturnType | undefined
+ const input = createRef()
+ function JoinedField() {
+ const ime = useImeEnterGestureOwnership()
+ const draft = useNativeChatDraft(scope, ime.isComposing)
+ send = useNativeChatStructuredComposerSend({
+ agent: 'codex',
+ draftScopeKey: scope,
+ imageAttachments: [],
+ structuredTransport: transport,
+ isComposing: ime.isComposing,
+ setDraft: draft.setDraft,
+ setCaret: vi.fn(),
+ clearSkillOrigin: vi.fn()
+ })
+ const events = useNativeChatDraftEvents({
+ ...draft,
+ setCaret: vi.fn(),
+ setActiveSuggestion: vi.fn(),
+ handleDraftOrCaretChange: vi.fn(),
+ flushPendingAttachments: vi.fn()
+ })
+ return (
+
+
+
+ )
+ }
+ const view = render()
+ let pending: Promise | undefined
+ act(() => {
+ pending = send!('/goal ship it', [])
+ })
+ const field = screen.getByRole('textbox')
+ fireEvent.compositionStart(field)
+ changePrompt(field, '/goal ship it가')
+ const edit = createNativeChatQueuedEditDraft(
+ {
+ kind: 'message',
+ role: 'user',
+ blocks: [{ type: 'text', text: '$review' }],
+ skills: [{ name: 'review', path: '/ime/SKILL.md' }]
+ },
+ scope
+ )
+ act(() => {
+ const draft = readNativeChatComposerDraft(edit.scopeKey)
+ updateNativeChatComposerDraft(
+ edit.scopeKey,
+ {
+ text: '$review changed',
+ document: {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ ...(draft.document?.content?.[0].content ?? []),
+ { type: 'text', text: ' changed' }
+ ]
+ }
+ ]
+ }
+ },
+ 'immediate'
+ )
+ handBackNativeChatQueuedEdit(edit)
+ discardNativeChatQueuedEdit(edit)
+ })
+ expect(promptValue(field)).toBe('/goal ship it가')
+ await act(async () => {
+ reply.resolve(true)
+ await pending
+ })
+ expect(promptValue(field)).toBe('/goal ship it가')
+ fireEvent.compositionEnd(field, { data: '가' })
+ expect(promptValue(field)).toBe('가\n\n$review changed')
+ const left = readNativeChatComposerDraft(scope)
+ expect(left.text).toBe('가\n\n$review changed')
+ expect(promptSkillRefs(left.document)).toEqual([{ name: 'review', path: '/ime/SKILL.md' }])
+ await act(async () => send!(left.text, []))
+ expect(transport.send).toHaveBeenCalledExactlyOnceWith(left.text, [], {
+ skills: [{ name: 'review', path: '/ime/SKILL.md' }]
+ })
+ view.unmount()
+ clearNativeChatComposerDraftsForTests()
+})
diff --git a/src/renderer/src/components/native-chat/native-chat-composer-destination.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-destination.test.ts
new file mode 100644
index 000000000000..986c20020193
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-composer-destination.test.ts
@@ -0,0 +1,72 @@
+// @vitest-environment happy-dom
+import { expect, it, vi } from 'vitest'
+import {
+ registerNativeChatComposerDestination,
+ nativeChatComposerDestination
+} from './native-chat-composer-destination'
+import {
+ captureInsertionTarget,
+ insertText,
+ registerDictationComposerTarget
+} from '../dictation/dictation-insertion-target'
+import type { NativeChatComposerHandle } from './native-chat-composer-types'
+
+function handle(): NativeChatComposerHandle {
+ return {
+ focus: vi.fn(() => true),
+ insertTypedText: vi.fn(() => true),
+ acceptsText: vi.fn(() => true),
+ appendText: vi.fn(),
+ handlePasteEvent: vi.fn(),
+ pasteFromClipboard: vi.fn(),
+ contains: vi.fn(() => true)
+ }
+}
+it('routes between two composers by focus, survives disabled-handle replacement and identity cleanup, and captures dictation', () => {
+ const pane = document.createElement('div')
+ const bottom = document.createElement('div')
+ const edit = document.createElement('div')
+ const bottomInput = document.createElement('textarea')
+ const editInput = document.createElement('textarea')
+ bottom.append(bottomInput)
+ edit.append(editInput)
+ pane.append(bottom, edit)
+ document.body.append(pane)
+ const bottomRef = { current: handle() }
+ const editRef = { current: handle() }
+ const releaseBottom = registerNativeChatComposerDestination(bottom, bottomRef, true)
+ const releaseEdit = registerNativeChatComposerDestination(edit, editRef, false)
+ const transcript = vi.fn()
+ const releaseDictation = registerDictationComposerTarget(edit, transcript)
+ try {
+ expect(nativeChatComposerDestination(pane, null)).toBe(bottomRef.current)
+ editInput.focus()
+ expect(nativeChatComposerDestination(pane, null)).toBe(editRef.current)
+ const captured = captureInsertionTarget()
+ bottomInput.focus()
+ expect(nativeChatComposerDestination(pane, null)).toBe(bottomRef.current)
+ editInput.focus()
+ editRef.current = handle()
+ expect(nativeChatComposerDestination(pane, null)).toBe(editRef.current)
+ const replacement = { current: handle() }
+ const releaseReplacement = registerNativeChatComposerDestination(edit, replacement, false)
+ releaseEdit()
+ editInput.focus()
+ edit.dispatchEvent(new Event('pointerdown'))
+ expect(nativeChatComposerDestination(pane, null)).toBe(replacement.current)
+ releaseReplacement()
+ releaseDictation()
+ edit.remove()
+ expect(nativeChatComposerDestination(pane, null)).toBe(bottomRef.current)
+ expect(captured?.kind).toBe('composer')
+ if (captured) {
+ insertText('late transcript', captured)
+ }
+ expect(transcript).toHaveBeenCalledExactlyOnceWith('late transcript')
+ } finally {
+ releaseBottom()
+ releaseEdit()
+ releaseDictation()
+ pane.remove()
+ }
+})
diff --git a/src/renderer/src/components/native-chat/native-chat-composer-destination.ts b/src/renderer/src/components/native-chat/native-chat-composer-destination.ts
new file mode 100644
index 000000000000..e15fd142dde2
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-composer-destination.ts
@@ -0,0 +1,77 @@
+import type { NativeChatComposerHandle } from './native-chat-composer-types'
+
+type Destination = {
+ handle: { current: NativeChatComposerHandle | null }
+ active: number
+ primary: boolean
+}
+const destinations = new WeakMap()
+const descendants = new WeakMap>()
+const scopes = new Map()
+let activation = 0
+
+/** Registration identity survives disabled transitions; toolbar clicks retain the last focused input. */
+export function registerNativeChatComposerDestination(
+ root: HTMLElement,
+ handle: { current: NativeChatComposerHandle | null },
+ primary: boolean,
+ scopeKey?: string
+): () => void {
+ const previous = destinations.get(root)
+ const entry = { handle, active: 0, primary }
+ destinations.set(root, entry)
+ if (scopeKey) {
+ scopes.set(scopeKey, entry)
+ }
+ const ancestors: HTMLElement[] = []
+ for (let parent: HTMLElement | null = root; parent; parent = parent.parentElement) {
+ ancestors.push(parent)
+ const entries = descendants.get(parent) ?? new Set()
+ if (previous) {
+ entries.delete(previous)
+ }
+ entries.add(entry)
+ descendants.set(parent, entries)
+ }
+ const activate = (): void => {
+ entry.active = ++activation
+ }
+ root.addEventListener('focusin', activate)
+ root.addEventListener('pointerdown', activate)
+ return () => {
+ if (scopeKey && scopes.get(scopeKey) === entry) {
+ scopes.delete(scopeKey)
+ }
+ root.removeEventListener('focusin', activate)
+ root.removeEventListener('pointerdown', activate)
+ for (const parent of ancestors) {
+ descendants.get(parent)?.delete(entry)
+ }
+ if (destinations.get(root) === entry) {
+ destinations.delete(root)
+ }
+ }
+}
+
+export function nativeChatComposerDestination(
+ root: HTMLElement | null,
+ fallback: NativeChatComposerHandle | null
+): NativeChatComposerHandle | null {
+ let selected: Destination | undefined
+ for (const entry of (root ? descendants.get(root) : undefined) ?? []) {
+ if (
+ !selected ||
+ entry.active > selected.active ||
+ (entry.active === selected.active && entry.primary)
+ ) {
+ selected = entry
+ }
+ }
+ return selected?.handle.current ?? fallback
+}
+
+export function nativeChatComposerScopeDestination(
+ scopeKey: string
+): NativeChatComposerHandle | null {
+ return scopes.get(scopeKey)?.handle.current ?? null
+}
diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.ts
index 99b58eb59320..aa5be6d7c83e 100644
--- a/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.ts
+++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.ts
@@ -1,7 +1,9 @@
// Keep additions replayable so late hydration and crash recovery merge returned text/images without replacing the draft.
+import type { JSONContent } from '@tiptap/react'
+import { promptTextContent, appendPromptDocumentText } from './native-chat-prompt-document-text'
import { appendReturnedDraftText } from '../../../../shared/returned-draft-text'
-import { appendPromptDocumentText } from './native-chat-prompt-document-text'
+import { isLoneStructuredAgentSessionConversationCommand } from '../../../../shared/structured-agent-session-composer'
import { basename } from '@/lib/path'
import type {
NativeChatComposerDraft,
@@ -10,6 +12,8 @@ import type {
export type NativeChatComposerDraftAddition = {
readonly text?: string
+ readonly document?: JSONContent
+ readonly pastedTextFiles?: Readonly>
readonly images?: readonly NativeChatComposerDraftImage[]
/** Only an image the user attaches takes the place of a placeholder with its file name. */
readonly fromUser?: boolean
@@ -45,19 +49,38 @@ export function placeNativeChatComposerDraftAddition(
export function withNativeChatComposerDraftAddition(
draft: NativeChatComposerDraft,
addition: NativeChatComposerDraftAddition
-): Pick {
+): Pick {
const fills = new Set(addition.fills)
// An existing unique image id proves the addition already landed, regardless of later typing.
if (
addition.images?.some(({ id }) => !fills.has(id) && draft.images.some((held) => held.id === id))
) {
- return { text: draft.text, images: draft.images, document: draft.document }
+ return {
+ text: draft.text,
+ images: draft.images,
+ document: draft.document,
+ pastedTextFiles: draft.pastedTextFiles
+ }
+ }
+ const returned = addition.text ?? ''
+ const kept = draft.text.trimEnd()
+ const replaces = kept === '' || isLoneStructuredAgentSessionConversationCommand(kept)
+ let text = appendReturnedDraftText(draft.text, returned)
+ const returnedBlocks = addition.document?.content ?? []
+ const heldBlocks = draft.document?.content ?? promptTextContent(draft.text).content ?? []
+ // Equal visible tokens do not establish equal selected files.
+ if (
+ addition.document &&
+ returned.trim() &&
+ text === draft.text &&
+ JSON.stringify(heldBlocks.slice(-returnedBlocks.length)) !== JSON.stringify(returnedBlocks)
+ ) {
+ text = `${kept}\n\n${returned}`
}
- const text = appendReturnedDraftText(draft.text, addition.text ?? '')
const images = [...draft.images]
let at = addition.before ? images.findIndex((held) => held.id === addition.before) : -1
- for (const { id, path, connectionId } of addition.images ?? []) {
- const image = { id, path, ...(connectionId ? { connectionId } : {}) }
+ for (const image of addition.images ?? []) {
+ const { id } = image
const held = images.findIndex((candidate) => candidate.id === id)
if (fills.has(id) && held !== -1) {
if (images[held].unavailableName !== undefined) {
@@ -69,5 +92,31 @@ export function withNativeChatComposerDraftAddition(
images.splice(at++, 0, image)
}
}
- return { text, images, document: appendPromptDocumentText(draft.document, draft.text, text) }
+ const document =
+ text === draft.text
+ ? draft.document
+ : addition.document
+ ? replaces
+ ? addition.document
+ : {
+ type: 'doc',
+ content: [
+ ...(appendPromptDocumentText(
+ draft.document ?? promptTextContent(draft.text),
+ draft.text,
+ kept
+ )?.content ?? []),
+ { type: 'paragraph' },
+ ...returnedBlocks
+ ]
+ }
+ : replaces
+ ? promptTextContent(text)
+ : appendPromptDocumentText(draft.document, draft.text, text)
+ return {
+ text,
+ images,
+ document,
+ pastedTextFiles: { ...draft.pastedTextFiles, ...addition.pastedTextFiles }
+ }
}
diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.ts
index 27c3df42a707..e11e0169d9f0 100644
--- a/src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.ts
+++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.ts
@@ -1,4 +1,6 @@
+import { textLeftAfterSubmission } from '../../../../shared/submitted-draft-text'
import type { JSONContent } from '@tiptap/react'
+import { slicePromptDocument } from './native-chat-prompt-document-text'
import type {
NativeChatComposerDraft,
NativeChatComposerDraftImage
@@ -36,16 +38,16 @@ export function nativeChatComposerDraftLeftAfterSend(
current: NativeChatComposerDraft,
sent: NativeChatComposerDraft
): NativeChatComposerDraft | null {
- let prefix = 0
- while (prefix < sent.text.length && sent.text[prefix] === current.text[prefix]) {
- prefix += 1
- }
- const rest = sent.text.slice(prefix)
- if (current.text.length < sent.text.length || !current.text.endsWith(rest)) {
+ const left = textLeftAfterSubmission(current.text, sent.text)
+ if (!left) {
return null
}
return {
- text: current.text.slice(prefix, current.text.length - rest.length),
+ text: left.text,
+ ...(current.document
+ ? { document: slicePromptDocument(current.document, left.from, left.to) }
+ : {}),
+ ...(current.pastedTextFiles ? { pastedTextFiles: current.pastedTextFiles } : {}),
images: current.images.filter((image) => !sent.images.some((held) => held.id === image.id))
}
}
diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-insertion.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-insertion.ts
new file mode 100644
index 000000000000..604bba128a46
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-insertion.ts
@@ -0,0 +1,38 @@
+import { nativeChatComposerScopeDestination } from './native-chat-composer-destination'
+import {
+ readNativeChatComposerDraft,
+ updateNativeChatComposerDraft
+} from './native-chat-composer-draft-store'
+import { notifyDraftAppend } from './native-chat-composer-draft-memory'
+import { appendPromptDocumentText } from './native-chat-prompt-document-text'
+import type {
+ NativeChatComposerDraft,
+ NativeChatComposerDraftOwner
+} from './native-chat-composer-draft-storage'
+
+/** Captured new input uses ordinary insertion, never recovery replay deduplication. */
+export function insertNativeChatComposerDraftText(
+ scope: string,
+ text: string,
+ options?: { paste?: boolean; owner?: NativeChatComposerDraftOwner }
+): void {
+ const mounted = nativeChatComposerScopeDestination(scope)
+ const insertMounted = options?.paste
+ ? (mounted?.insertPastedText ?? mounted?.insertTypedText)
+ : mounted?.insertTypedText
+ if (!mounted?.isComposing?.() && insertMounted?.(text)) {
+ return
+ }
+ const insert = (draft: T): T => {
+ const next = `${draft.text}${draft.text && !/\s$/.test(draft.text) ? ' ' : ''}${text}`
+ return {
+ ...draft,
+ ...(options?.owner ? { owner: options.owner } : {}),
+ text: next,
+ document: appendPromptDocumentText(draft.document, draft.text, next)
+ }
+ }
+ const before = readNativeChatComposerDraft(scope)
+ updateNativeChatComposerDraft(scope, insert(before), 'immediate', insert)
+ notifyDraftAppend(scope, insert, before)
+}
diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-journal.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-journal.ts
index 8ac56cae3409..45f61ae5b11a 100644
--- a/src/renderer/src/components/native-chat/native-chat-composer-draft-journal.ts
+++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-journal.ts
@@ -45,6 +45,8 @@ function parseEntry(entry: unknown): JournalEntry[] {
}
const parsed = parseStoredNativeChatComposerDraft({
text: typeof addition.text === 'string' ? addition.text : '',
+ document: addition.document,
+ pastedTextFiles: addition.pastedTextFiles,
images: Array.isArray(addition.images) ? addition.images : [],
savedAt: at
})
@@ -57,6 +59,8 @@ function parseEntry(entry: unknown): JournalEntry[] {
addition: {
text: parsed.text,
images: parsed.images,
+ document: parsed.document,
+ pastedTextFiles: parsed.pastedTextFiles,
...(addition.fromUser === true ? { fromUser: true } : {}),
...(typeof addition.before === 'string' ? { before: addition.before } : {}),
...(Array.isArray(addition.fills)
diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-memory.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-memory.ts
index 8fde5f3207b9..25a8658091b8 100644
--- a/src/renderer/src/components/native-chat/native-chat-composer-draft-memory.ts
+++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-memory.ts
@@ -1,6 +1,8 @@
+import type { NativeChatComposerDraftAddition } from './native-chat-composer-draft-addition'
// Memory owns drafts; storage keeps a copy shared by the API and persistence modules.
import type {
+ NativeChatComposerDraft,
NativeChatComposerDraftImage,
StoredNativeChatComposerDraft
} from './native-chat-composer-draft-storage'
@@ -122,3 +124,63 @@ export function clearDraftMemoryForTests(): void {
load.appendsBeforeLoad.clear()
load.deletionsBeforeLoad.length = 0
}
+
+export type NativeChatDraftAppend =
+ | NativeChatComposerDraftAddition
+ | ((draft: NativeChatComposerDraft) => NativeChatComposerDraft)
+const appendListeners = new Map<
+ string,
+ Set<(addition: NativeChatDraftAppend, previous: NativeChatComposerDraft) => void>
+>()
+export function subscribeToNativeChatComposerDraftAppend(
+ scope: string,
+ listener: (addition: NativeChatDraftAppend, previous: NativeChatComposerDraft) => void
+): () => void {
+ const scoped = appendListeners.get(scope) ?? new Set()
+ appendListeners.set(scope, scoped)
+ scoped.add(listener)
+ return () => {
+ scoped.delete(listener)
+ if (scoped.size === 0) {
+ appendListeners.delete(scope)
+ }
+ }
+}
+
+export function notifyDraftAppend(
+ scope: string,
+ addition: NativeChatDraftAppend,
+ previous: NativeChatComposerDraft
+): void {
+ appendListeners.get(scope)?.forEach((listener) => listener(addition, previous))
+}
+
+export type NativeChatEditDraftDestination = { scopeKey: string; active: boolean }
+export const ephemeralDrafts = new Map()
+
+export function captureNativeChatEditDraftDestination(
+ scopeKey: string
+): NativeChatEditDraftDestination | undefined {
+ return ephemeralDrafts.get(scopeKey)
+}
+
+export function redirectNativeChatEditDraft(scopeKey: string, destination: string): void {
+ const target = ephemeralDrafts.get(scopeKey)
+ if (target) {
+ target.scopeKey = destination
+ }
+}
+
+export function isNativeChatEditDraft(scopeKey: string): boolean {
+ return scopeKey.startsWith('queued-edit:')
+}
+
+export function closeNativeChatEditDraft(scopeKey: string): void {
+ const target = ephemeralDrafts.get(scopeKey)
+ if (target?.scopeKey === scopeKey) {
+ target.active = false
+ }
+ ephemeralDrafts.delete(scopeKey)
+ records.delete(scopeKey)
+ notifyScope(scopeKey)
+}
diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts
index e9a457261d9c..ff62ad9b42bb 100644
--- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts
+++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts
@@ -22,6 +22,9 @@ import {
records,
refusedScopes,
scopeListeners,
+ notifyDraftAppend,
+ ephemeralDrafts,
+ isNativeChatEditDraft,
reconcileRestoredImageChecks,
type DraftAppend,
type DraftRecord
@@ -71,6 +74,12 @@ export type NativeChatComposerDraftChange = {
owner?: NativeChatComposerDraftOwner
}
+/** Edit scopes share input machinery but never enter draft persistence. */
+export function openNativeChatEditDraft(scopeKey: string, draft: NativeChatComposerDraft): void {
+ ephemeralDrafts.set(scopeKey, { scopeKey, active: true })
+ updateNativeChatComposerDraft(scopeKey, draft, 'immediate')
+}
+
const EMPTY_DRAFT: DraftRecord = { text: '', images: [], savedAt: 0 }
let resolveOwner: ((scopeKey: string) => NativeChatComposerDraftOwner | undefined) | null = null
@@ -125,6 +134,9 @@ export function updateNativeChatComposerDraft(
persist: 'immediate' | 'deferred',
append?: DraftAppend
): void {
+ if (isNativeChatEditDraft(scopeKey) && !ephemeralDrafts.has(scopeKey)) {
+ return
+ }
const current = records.get(scopeKey) ?? EMPTY_DRAFT
const text = change.text ?? current.text
const document = 'document' in change ? change.document : current.document
@@ -151,7 +163,9 @@ export function updateNativeChatComposerDraft(
records.delete(scopeKey)
reconcileRestoredImageChecks(scopeKey, [])
notifyScope(scopeKey)
- persistNativeChatComposerDraft(scopeKey, 'immediate', append)
+ if (!isNativeChatEditDraft(scopeKey)) {
+ persistNativeChatComposerDraft(scopeKey, 'immediate', append)
+ }
return
}
// Why stamped once: a conversation never moves to another workspace, so its owner stays true.
@@ -168,6 +182,9 @@ export function updateNativeChatComposerDraft(
records.set(scopeKey, record)
reconcileRestoredImageChecks(scopeKey, record.images)
notifyScope(scopeKey)
+ if (isNativeChatEditDraft(scopeKey)) {
+ return
+ }
persistNativeChatComposerDraft(scopeKey, persist, append)
if (!load.hydrated) {
// Why: a window whose startup never started the load still gets its saved drafts.
@@ -191,18 +208,22 @@ export function appendToNativeChatComposerDraft(
const before = records.get(scopeKey)
const current = readNativeChatComposerDraft(scopeKey)
const placed = placeNativeChatComposerDraftAddition(current, addition)
- const { text, images, document } = withAddition(current, placed)
+ const { text, images, document, pastedTextFiles } = withAddition(current, placed)
updateNativeChatComposerDraft(
scopeKey,
{
text,
images,
document,
+ pastedTextFiles,
...(owner ? { owner } : {})
},
'immediate',
(loaded) => withAddition(loaded, placed)
)
+ if (text !== current.text) {
+ notifyDraftAppend(scopeKey, placed, current)
+ }
const after = records.get(scopeKey)
if (!after) {
return true
@@ -211,7 +232,10 @@ export function appendToNativeChatComposerDraft(
if (after === before && !hasLocalChange(scopeKey) && !refusedScopes.has(scopeKey)) {
return true
}
- return journalNativeChatComposerDraftAddition(scopeKey, placed, after.savedAt)
+ return (
+ isNativeChatEditDraft(scopeKey) ||
+ journalNativeChatComposerDraftAddition(scopeKey, placed, after.savedAt)
+ )
}
/** Clear only the sent draft’s unchanged portion so late acceptance cannot erase newer typing or attachments. */
@@ -289,9 +313,20 @@ export function deleteNativeChatComposerDraftsOwnedBy(owner: NativeChatComposerD
}
export function clearNativeChatComposerDraftsForTests(): void {
+ ephemeralDrafts.clear()
clearDraftMemoryForTests()
resetNativeChatComposerDraftPersistenceForTests()
resetNativeChatComposerDraftLoadForTests()
setNativeChatComposerDraftStorageForTests(null)
resolveOwner = null
}
+
+export { subscribeToNativeChatComposerDraftAppend } from './native-chat-composer-draft-memory'
+
+export {
+ captureNativeChatEditDraftDestination,
+ redirectNativeChatEditDraft,
+ isNativeChatEditDraft,
+ closeNativeChatEditDraft,
+ type NativeChatEditDraftDestination
+} from './native-chat-composer-draft-memory'
diff --git a/src/renderer/src/components/native-chat/native-chat-composer-paste-options.ts b/src/renderer/src/components/native-chat/native-chat-composer-paste-options.ts
index 2cf091abecca..eb47cca1ccc0 100644
--- a/src/renderer/src/components/native-chat/native-chat-composer-paste-options.ts
+++ b/src/renderer/src/components/native-chat/native-chat-composer-paste-options.ts
@@ -14,6 +14,7 @@ export type UseNativeChatComposerPasteArgs = {
attachResolvedPaths: (paths: string[], connectionId?: string | null) => void
/** Files a file manager copied attach through the composer's drop route. */
attachCopiedFiles: (paths: string[], options?: { focus?: boolean }) => void
+ captureExternalPaths?: () => (paths: string[]) => void | Promise
/** Capture reserved-chip ownership before clipboard awaits so file resolution survives composer unmount. */
captureCopiedFiles: (options?: {
focus?: boolean
diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts
index b05855ac30bf..b36a774c5f0d 100644
--- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts
+++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts
@@ -83,6 +83,8 @@ export type NativeChatOptimisticSendOutcome = {
export type NativeChatComposerProps = {
/** Tab hosting the agent; used to resolve the live ptyId + runtime settings. */
terminalTabId: string
+ /** Input-only edit session: every submit saves, Escape cancels, and send-only actions are absent. */
+ editSession?: { save: () => void; cancel: () => void; canSave: boolean }
/** Stable split-leaf identity; unlike a PTY id, this survives reconnects. */
paneKey: string
/** Unsent-draft owner; defaults to paneKey, while structured chats share conversation scope. */
@@ -142,8 +144,10 @@ export type NativeChatLaunchSeed = {
}
export type NativeChatComposerHandle = {
+ isComposing?: () => boolean
focus: () => boolean
insertTypedText: (text: string) => boolean
+ insertPastedText?: (text: string) => boolean
/** Whether the input is there and enabled, so text given to it lands. */
acceptsText: () => boolean
/** Append deduplicated text after a blank line and place the caret at its end. */
diff --git a/src/renderer/src/components/native-chat/native-chat-composer-upload-completion.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-upload-completion.test.tsx
index 64605336f68e..5bb7e4b8a190 100644
--- a/src/renderer/src/components/native-chat/native-chat-composer-upload-completion.test.tsx
+++ b/src/renderer/src/components/native-chat/native-chat-composer-upload-completion.test.tsx
@@ -195,10 +195,10 @@ it('does not attach a file reference removed while uploading', async () => {
expect(result.target.text).toBe('next question')
})
-it('keeps the original file owner when the mounted composer switches draft scopes', async () => {
+it('keeps original file ownership and ordinary insertion when the mounted composer switches 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.target.text).toBe(`next question @${result.path}`)
expect(result.otherInput).toBe('other question')
expect(result.other.images).toEqual([])
})
diff --git a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts
index 3c73dc419c7b..67762eeaec44 100644
--- a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts
+++ b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts
@@ -35,9 +35,6 @@ export function writeNativeChatDraftCache(
)
}
-// Why: a composer mid-IME-composition keeps showing what it had, so it is told what was appended.
-const appendListeners = new Map void>>()
-
/** Append returned text and notify mounted composers; true means durable hand-back so its source copy may go. */
export function appendNativeChatDraftCache(
scopeKey: string,
@@ -49,28 +46,7 @@ export function appendNativeChatDraftCache(
if (text === '' && !images?.length) {
return true
}
- const previous = readNativeChatDraftCache(scopeKey)
- const durable = appendToNativeChatComposerDraft(scopeKey, { text, images }, owner)
- // Why: an addition the draft already held adds nothing, so a composer must not show it again.
- if (readNativeChatDraftCache(scopeKey) !== previous) {
- appendListeners.get(scopeKey)?.forEach((listener) => listener(text, previous))
- }
- return durable
-}
-
-export function subscribeToNativeChatDraftAppend(
- scopeKey: string,
- listener: (text: string, previous: string) => void
-): () => void {
- const listeners = appendListeners.get(scopeKey) ?? new Set()
- appendListeners.set(scopeKey, listeners)
- listeners.add(listener)
- return () => {
- listeners.delete(listener)
- if (listeners.size === 0 && appendListeners.get(scopeKey) === listeners) {
- appendListeners.delete(scopeKey)
- }
- }
+ return appendToNativeChatComposerDraft(scopeKey, { text, images }, owner)
}
export function clearNativeChatDraftCacheForTests(): void {
diff --git a/src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx b/src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx
index fca6a5dd9b20..35cac3180df9 100644
--- a/src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx
+++ b/src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx
@@ -4,6 +4,16 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as AttachmentUploadModule from './native-chat-attachment-upload'
import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload'
+import {
+ createNativeChatQueuedEditDraft,
+ handBackNativeChatQueuedEdit,
+ discardNativeChatQueuedEdit
+} from './native-chat-queued-edit-draft'
+import {
+ readNativeChatComposerDraft,
+ clearNativeChatComposerDraftsForTests
+} from './native-chat-composer-draft-store'
+import { useNativeChatFileAttachmentActions } from './use-native-chat-file-attachment-actions'
import { nativeChatPendingAttachmentSnapshot } from './native-chat-pending-attachment-cache'
import { nativeChatImagesHoldSend } from './native-chat-image-reattach'
import { readNativeChatDraftCache } from './native-chat-draft-cache'
@@ -52,7 +62,8 @@ vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({
type Api = ReturnType &
ReturnType &
- ReturnType
+ ReturnType &
+ ReturnType
let api: Api | undefined
let root: Root
let container: HTMLDivElement
@@ -61,18 +72,20 @@ let composing = true
function Probe({
worktreeId,
acceptsImages = true,
- disabled = false
+ disabled = false,
+ scope = 'drop-test'
}: {
worktreeId?: string
acceptsImages?: boolean
disabled?: boolean
+ scope?: string
}): React.JSX.Element {
const [draft, setDraft] = useState('')
const [caret, setCaret] = useState(0)
const [notice, setNotice] = useState(null)
const textareaRef = useRef(null)
const attachments = useNativeChatComposerAttachments({
- attachmentScopeKey: 'drop-test',
+ attachmentScopeKey: scope,
acceptsImages,
caret,
disabled,
@@ -85,6 +98,7 @@ function Probe({
})
const external = useNativeChatExternalAttachments({
terminalTabId: 'tab-1',
+ draftScopeKey: scope,
structuredWorktreeId: worktreeId,
disabled,
attachResolvedPaths: attachments.attachResolvedPaths,
@@ -92,7 +106,7 @@ function Probe({
setNotice
})
const paste = useNativeChatComposerPaste({
- attachmentScopeKey: 'drop-test',
+ attachmentScopeKey: scope,
agent: 'claude',
disabled,
caret,
@@ -100,6 +114,7 @@ function Probe({
resolveAttachmentOwner: external.resolveAttachmentOwner,
attachResolvedPaths: attachments.attachResolvedPaths,
attachCopiedFiles: external.attachExternalPaths,
+ captureExternalPaths: () => external.captureExternalDrop(undefined, { focus: false }),
captureCopiedFiles: (options) => external.captureReservedAttachments(undefined, options),
beginPendingImageAttachment: attachments.beginPendingImageAttachment,
resolvePendingImageAttachment: attachments.pendingChips.resolve,
@@ -108,7 +123,8 @@ function Probe({
insertTypedText: () => true,
setNotice
})
- api = { ...attachments, ...external, ...paste }
+ const picker = useNativeChatFileAttachmentActions(() => external.captureExternalDrop())
+ api = { ...attachments, ...external, ...paste, ...picker }
return (
<>
@@ -155,6 +171,7 @@ afterEach(() => {
act(() => root.unmount())
container.remove()
clearNativeChatAttachmentCacheForTests()
+ clearNativeChatComposerDraftsForTests()
vi.unstubAllGlobals()
})
@@ -444,3 +461,83 @@ describe('a macOS paste the clipboard shows is Finder files', () => {
expect(mocks.stat).not.toHaveBeenCalled()
})
})
+
+describe('captured edit picker and menu destination', () => {
+ it.each(['picker', 'menu'] as const)(
+ 'keeps a deferred %s result with the original bottom after hand-back, and retires it on Cancel',
+ async (route) => {
+ for (const cancel of [false, true]) {
+ const edit = createNativeChatQueuedEditDraft(
+ { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'original' }] },
+ 'original-bottom'
+ )
+ let pickerAnswer = (_paths: string[]) => {}
+ let clipboardAnswer = (_read: string) => {}
+ window.api.shell = {
+ ...window.api.shell,
+ pickAttachments: () =>
+ new Promise((resolve) => {
+ pickerAnswer = resolve
+ })
+ }
+ window.api.ui.readClipboardFilePaths = async () => ['/late.txt', '/late.png']
+ window.api.ui.clipboardHasImage = async () => false
+ window.api.ui.readClipboardText = () =>
+ new Promise((resolve) => {
+ clipboardAnswer = resolve
+ })
+ composing = false
+ await act(async () => root.render())
+ act(() => (route === 'picker' ? latest().pickAttachments() : latest().pasteFromClipboard()))
+ if (cancel) {
+ discardNativeChatQueuedEdit(edit)
+ } else {
+ handBackNativeChatQueuedEdit(edit)
+ }
+ act(() => root.unmount())
+ await act(async () => {
+ pickerAnswer(['/late.txt', '/late.png'])
+ clipboardAnswer('/late.txt\n/late.png')
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ })
+ const bottom = readNativeChatComposerDraft('original-bottom')
+ expect(bottom.text).toBe(cancel ? '' : '@/late.txt')
+ expect(bottom.images.map(({ path }) => path)).toEqual(cancel ? [] : ['/late.png'])
+ expect(nativeChatPendingAttachmentSnapshot('original-bottom')).toEqual([])
+ root = createRoot(container)
+ clearNativeChatComposerDraftsForTests()
+ }
+ }
+ )
+})
+
+it.each(['ordinary', 'edit'] as const)(
+ 'keeps unreadable-picker failure outcome on the moved %s entry point',
+ async (kind) => {
+ const edit =
+ kind === 'edit'
+ ? createNativeChatQueuedEditDraft(
+ { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'original' }] },
+ 'bottom'
+ )
+ : undefined
+ const scope = edit?.scopeKey ?? 'drop-test'
+ composing = false
+ mocks.stat.mockRejectedValue(new Error('unreadable'))
+ window.api.shell = { ...window.api.shell, pickAttachments: async () => ['/unreadable.png'] }
+ await act(async () => root.render())
+ await act(async () => {
+ latest().pickAttachments()
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ })
+ expect(nativeChatPendingAttachmentSnapshot(scope)).toEqual([])
+ expect(latest().imageAttachments).toEqual([])
+ expect(readNativeChatComposerDraft(scope).text).toBe(edit ? 'original' : '')
+ expect(container.querySelector('[data-notice]')?.textContent).toBe(
+ "Couldn't read the dropped files."
+ )
+ if (edit) {
+ discardNativeChatQueuedEdit(edit)
+ }
+ }
+)
diff --git a/src/renderer/src/components/native-chat/native-chat-pane-file-drop.test.tsx b/src/renderer/src/components/native-chat/native-chat-pane-file-drop.test.tsx
index 2b80d73f522d..e955fedb9a55 100644
--- a/src/renderer/src/components/native-chat/native-chat-pane-file-drop.test.tsx
+++ b/src/renderer/src/components/native-chat/native-chat-pane-file-drop.test.tsx
@@ -1,9 +1,8 @@
// @vitest-environment happy-dom
+import { useState } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { act, cleanup, render, screen } from '@testing-library/react'
-import { TooltipProvider } from '@/components/ui/tooltip'
-import { NativeChatQueuedMessageEditor } from './NativeChatQueuedMessageEditor'
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import type * as NativeChatAttachmentUpload from './native-chat-attachment-upload'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
import {
@@ -25,46 +24,6 @@ vi.mock('./native-chat-attachment-upload', async (original) => ({
const OVERLAY = '[data-native-chat-drop-overlay="true"]'
const IGNORE_DRAG = (): void => {}
-function EditingCard({ disabled = false }: { disabled?: boolean }) {
- return (
-
- null,
- resolve: () => false,
- reveal: IGNORE_DRAG,
- drop: () => false,
- append: () => [],
- remove: IGNORE_DRAG
- }
- }
- }}
- attachTo={{
- terminalTabId: 'tab',
- transport: {
- sessionId: 's',
- worktreeId: 'wt',
- runtimeEnvironmentId: null,
- acceptsImages: true
- }
- }}
- />
-
- )
-}
-
class DropDataTransfer {
dropEffect = 'none'
effectAllowed = 'all'
@@ -154,53 +113,6 @@ describe('nativeChatPaneDragKind', () => {
})
describe('NativeChatPaneFileDropSurface', () => {
- it('scopes OS and in-app hover to the editing card, clears on exit/end and respects disabled intake', () => {
- const { container, rerender } = renderPane(
- <>
-
-
- >
- )
- const edit = screen.getByRole('textbox').closest('[data-queued-message-editor]')
- if (!(edit instanceof HTMLElement)) {
- throw new Error('Expected editing card')
- }
- for (const drag of [osDrag(), workspaceDrag()]) {
- fireDrag(screen.getByTestId('transcript'), 'dragover', drag)
- fireDrag(screen.getByRole('textbox'), 'dragover', drag)
- expect(container.querySelectorAll(OVERLAY)).toHaveLength(1)
- expect(edit.querySelector(OVERLAY)?.textContent).toContain('Drop to attach to this message')
- fireDrag(
- screen.getByRole('textbox'),
- 'dragleave',
- drag,
- screen.getByRole('button', { name: 'Attach file' })
- )
- expect(edit.querySelector(OVERLAY)).not.toBeNull()
- fireDrag(
- screen.getByRole('button', { name: 'Attach file' }),
- 'dragleave',
- drag,
- screen.getByTestId('transcript')
- )
- expect(edit.querySelector(OVERLAY)).toBeNull()
- fireDrag(screen.getByRole('textbox'), 'dragover', drag)
- act(() => document.dispatchEvent(new Event('dragend', { bubbles: true })))
- expect(edit.querySelector(OVERLAY)).toBeNull()
- fireDrag(screen.getByRole('textbox'), 'dragover', drag)
- fireDrag(screen.getByRole('textbox'), 'drop', drag)
- expect(container.querySelector(OVERLAY)).toBeNull()
- }
- rerender(
-
-
-
-
- )
- fireDrag(screen.getByRole('textbox'), 'dragover', osDrag())
- expect(container.querySelector(OVERLAY)).toBeNull()
- })
-
it('routes a drop on the transcript to the composer that claimed the pane', () => {
const onDropCapture = vi.fn()
const { transcript, container } = renderPane()
@@ -347,3 +259,68 @@ describe('NativeChatPaneFileDropSurface', () => {
}
})
})
+
+it('direct drops override last focus; background keeps stable focus through disable and remount, with questions independent', () => {
+ function Composer({ scope, disabled = false }: { scope: string; disabled?: boolean }) {
+ const [value, setValue] = useState('')
+ useNativeChatPaneFileDropClaim({
+ destinationKey: scope,
+ focusScopeKey: scope,
+ disabled,
+ captureExternalDrop: () => async () => {},
+ onDragOverCapture: (event) => {
+ if (!disabled) {
+ event.preventDefault()
+ }
+ },
+ onDropCapture: (event) => {
+ if (!disabled) {
+ setValue((previous) => previous + event.dataTransfer.getData(WORKSPACE_FILE_PATH_MIME))
+ }
+ }
+ })
+ return (
+
+
+
+ )
+ }
+ function Pane({ disabled = false, revision = 0 }: { disabled?: boolean; revision?: number }) {
+ return (
+
+ background
+
+
+
+
+ )
+ }
+ const view = render()
+ const input = (label: string): HTMLTextAreaElement => {
+ const element = view.getByLabelText(label)
+ if (!(element instanceof HTMLTextAreaElement)) {
+ throw new Error('expected composer textarea')
+ }
+ return element
+ }
+ const bottom = () => input('bottom')
+ const edit = () => input('queued-edit:row')
+ fireEvent.focus(edit())
+ fireDrag(bottom(), 'drop', workspaceDrag())
+ expect(bottom().value).toBe('/repo/a.ts')
+ expect(edit().value).toBe('')
+ fireDrag(view.getByText('background'), 'drop', workspaceDrag())
+ expect(edit().value).toBe('/repo/a.ts')
+ fireEvent.focus(view.getByLabelText('question'))
+ fireDrag(view.getByText('background'), 'drop', workspaceDrag())
+ expect(edit().value).toBe('/repo/a.ts/repo/a.ts')
+ view.rerender()
+ fireDrag(edit(), 'drop', workspaceDrag())
+ expect(bottom().value).toBe('/repo/a.ts')
+ view.rerender()
+ fireDrag(view.getByText('background'), 'drop', workspaceDrag())
+ expect(edit().value).toBe('/repo/a.ts/repo/a.ts/repo/a.ts')
+ view.rerender()
+ fireDrag(view.getByText('background'), 'drop', workspaceDrag())
+ expect(edit().value).toBe('/repo/a.ts')
+})
diff --git a/src/renderer/src/components/native-chat/native-chat-pane-file-drop.ts b/src/renderer/src/components/native-chat/native-chat-pane-file-drop.ts
index 73dc39abacb8..04616c676a93 100644
--- a/src/renderer/src/components/native-chat/native-chat-pane-file-drop.ts
+++ b/src/renderer/src/components/native-chat/native-chat-pane-file-drop.ts
@@ -19,6 +19,7 @@ export type CapturedNativeChatDrop = ((paths: string[]) => Promise) & {
}
export type NativeChatPaneDropClaim = {
+ focusScopeKey?: string
destinationKey: string
captureExternalDrop: () => CapturedNativeChatDrop
/** A guarded composer refuses the drop, so the pane must not invite one. */
@@ -82,7 +83,7 @@ function overNestedOwner(event: NativeChatPaneDragEvent): boolean {
/** Preserve workspace-file provenance; OS drops use the nearest native owner. */
export function makeNativeChatPaneFileDropHandlers(host: {
- getClaim: () => NativeChatPaneDropClaim | null
+ getClaim: (event: NativeChatPaneDragEvent) => NativeChatPaneDropClaim | null
setDragActive: (active: boolean) => void
}): {
onDragEnterCapture: (event: React.DragEvent) => void
@@ -106,7 +107,7 @@ export function makeNativeChatPaneFileDropHandlers(host: {
hoveredOwner = owner
hover = next
const kind = nativeChatPaneDragKind(event.dataTransfer)
- const claim = host.getClaim()
+ const claim = host.getClaim(event)
hover?.(kind === 'os' || (kind === 'workspace' && claim !== null && !claim.disabled))
}
const showsDropTarget = (event: NativeChatPaneDragEvent): boolean => {
@@ -114,7 +115,7 @@ export function makeNativeChatPaneFileDropHandlers(host: {
if (kind === null || overNestedOwner(event)) {
return false
}
- const claim = host.getClaim()
+ const claim = host.getClaim(event)
return claim !== null && !claim.disabled
}
@@ -133,7 +134,7 @@ export function makeNativeChatPaneFileDropHandlers(host: {
host.setDragActive(false)
}
if (nativeChatPaneDragKind(event.dataTransfer) === 'workspace') {
- host.getClaim()?.onDragOverCapture(event)
+ host.getClaim(event)?.onDragOverCapture(event)
}
},
onDragLeaveCapture(event) {
@@ -151,7 +152,7 @@ export function makeNativeChatPaneFileDropHandlers(host: {
if (nativeChatPaneDragKind(event.dataTransfer) === 'workspace') {
const owner = nestedOwner(event)
host
- .getClaim()
+ .getClaim(event)
?.onDropCapture(event, owner ? nestedWorkspaceDropSinks.get(owner)?.sink : undefined)
}
}
diff --git a/src/renderer/src/components/native-chat/native-chat-paste-destination.ts b/src/renderer/src/components/native-chat/native-chat-paste-destination.ts
new file mode 100644
index 000000000000..12f25e2f815f
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-paste-destination.ts
@@ -0,0 +1,57 @@
+import { insertNativeChatComposerDraftText } from './native-chat-composer-draft-insertion'
+import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
+import { captureNativeChatEditDraftDestination } from './native-chat-composer-draft-store'
+import { nativeChatPasteUnavailableNotice } from '@/lib/native-chat-paste-request'
+
+function captureNativeChatPasteDestination(
+ scope: string | undefined,
+ lifetime: { active: boolean },
+ canPaste: () => boolean,
+ insertTypedText: (text: string) => boolean,
+ setNotice: (notice: string | null) => void
+) {
+ const destination = scope ? captureNativeChatEditDraftDestination(scope) : undefined
+ const canComplete = (): boolean => (destination ? destination.active : canPaste())
+ const insertCapturedText = (text: string): void => {
+ if (!text || !canComplete()) {
+ return
+ }
+ if (
+ (!destination || destination.scopeKey === scope) &&
+ lifetime.active &&
+ insertTypedText(text)
+ ) {
+ return
+ }
+ if (destination) {
+ insertNativeChatComposerDraftText(destination.scopeKey, text, { paste: true })
+ } else {
+ setNotice(nativeChatPasteUnavailableNotice())
+ }
+ }
+ const showPasteUnavailable = (): void => {
+ if (lifetime.active) {
+ setNotice(nativeChatPasteUnavailableNotice())
+ }
+ }
+ return { canComplete, insertCapturedText, showPasteUnavailable }
+}
+
+export function useNativeChatPasteDestination(
+ scope: string | undefined,
+ lifetime: { active: boolean },
+ disabled: boolean,
+ insertTypedText: (text: string) => boolean,
+ setNotice: (notice: string | null) => void
+) {
+ const disabledRef = useRef(disabled)
+ useLayoutEffect(() => {
+ disabledRef.current = disabled
+ }, [disabled])
+ const canPaste = useCallback(() => lifetime.active && !disabledRef.current, [lifetime])
+ const captured = useMemo(
+ () => captureNativeChatPasteDestination(scope, lifetime, canPaste, insertTypedText, setNotice),
+ [scope, lifetime, canPaste, insertTypedText, setNotice]
+ )
+ return { canPaste, ...captured }
+}
diff --git a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts
index d1d2766f7fe1..ccee6fb35497 100644
--- a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts
+++ b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts
@@ -1,12 +1,10 @@
+import { insertNativeChatComposerDraftText } from './native-chat-composer-draft-insertion'
// Pending chips outlive composers in memory; completed images persist immediately in their draft.
import { useCallback, useSyncExternalStore } from 'react'
import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
import type { NativeChatComposerDraftOwner } from './native-chat-composer-draft-storage'
-import {
- appendNativeChatAttachmentCache,
- appendNativeChatDraftCache
-} from './native-chat-draft-cache'
+import { appendNativeChatAttachmentCache } from './native-chat-draft-cache'
import { formatNativeChatFileReference } from '../../../../shared/agent-image-paste'
import {
appendToNativeChatComposerDraft,
@@ -19,6 +17,7 @@ import {
const EMPTY: readonly NativeChatComposerImageAttachment[] = Object.freeze([])
const pendingCache = new Map()
// Capture ownership before tab removal can erase it.
+const chipScopes = new Map()
const pendingOwners = new Map()
const listeners = new Map void>>()
@@ -39,6 +38,14 @@ export function nativeChatComposerHasUserContent(scopeKey: string): boolean {
}
function writePending(scopeKey: string, next: readonly NativeChatComposerImageAttachment[]): void {
+ for (const chip of pendingCache.get(scopeKey) ?? []) {
+ if (chipScopes.get(chip.id) === scopeKey) {
+ chipScopes.delete(chip.id)
+ }
+ }
+ for (const chip of next) {
+ chipScopes.set(chip.id, scopeKey)
+ }
if (next.length === 0) {
pendingCache.delete(scopeKey)
pendingOwners.delete(scopeKey)
@@ -90,6 +97,7 @@ export function takeNativeChatPendingAttachment(
scopeKey: string,
id: string
): NativeChatComposerImageAttachment | undefined {
+ scopeKey = pendingDestination(scopeKey, id)
const current = nativeChatPendingAttachmentSnapshot(scopeKey)
const taken = current.find((attachment) => attachment.id === id && attachment.pending)
if (taken) {
@@ -134,6 +142,17 @@ export function nativeChatImageRow(
return [...images.flatMap((image) => [...(ahead.get(image.id) ?? []), image]), ...waiting]
}
+/** Move outstanding operations with their draft; completion resolves by chip identity after the source closes. */
+export function moveNativeChatPendingAttachments(from: string, to: string): void {
+ const pending = nativeChatPendingAttachmentSnapshot(from)
+ writePending(to, [...nativeChatPendingAttachmentSnapshot(to), ...pending])
+ writePending(from, [])
+}
+
+function pendingDestination(scope: string, id: string): string {
+ return chipScopes.get(id) ?? scope
+}
+
/** Persist completed images in attachment order; return false if already removed. */
export function settleNativeChatPendingAttachment(
scopeKey: string,
@@ -141,6 +160,7 @@ export function settleNativeChatPendingAttachment(
path: string,
connectionId?: string | null
): boolean {
+ scopeKey = pendingDestination(scopeKey, id)
const current = nativeChatPendingAttachmentSnapshot(scopeKey)
const index = current.findIndex((chip) => chip.id === id && chip.pending)
if (index === -1) {
@@ -178,6 +198,8 @@ export function settleNativeChatPendingAttachmentReferences(
references: { id: string; path: string }[],
insertAtCaret?: (paths: string[]) => void
): boolean {
+ const originalScope = scopeKey
+ scopeKey = references.length ? pendingDestination(scopeKey, references[0].id) : scopeKey
const owner = pendingOwners.get(scopeKey)
const paths = references.flatMap(({ id, path }) =>
takeNativeChatPendingAttachment(scopeKey, id) ? [path] : []
@@ -185,10 +207,14 @@ export function settleNativeChatPendingAttachmentReferences(
if (paths.length === 0) {
return false
}
- if (insertAtCaret) {
+ if (insertAtCaret && scopeKey === originalScope) {
insertAtCaret(paths)
} else {
- appendNativeChatDraftCache(scopeKey, paths.map(formatNativeChatFileReference).join(' '), owner)
+ insertNativeChatComposerDraftText(
+ scopeKey,
+ paths.map(formatNativeChatFileReference).join(' '),
+ { owner }
+ )
}
return true
}
@@ -227,6 +253,7 @@ export function dropNativeChatPendingAttachmentsOwnedBy(owner: NativeChatCompose
}
export function clearNativeChatPendingAttachmentsForTests(): void {
+ chipScopes.clear()
pendingCache.clear()
pendingOwners.clear()
}
diff --git a/src/renderer/src/components/native-chat/native-chat-prompt-document-text.ts b/src/renderer/src/components/native-chat/native-chat-prompt-document-text.ts
index 712244b2a3a4..c022d71f48fd 100644
--- a/src/renderer/src/components/native-chat/native-chat-prompt-document-text.ts
+++ b/src/renderer/src/components/native-chat/native-chat-prompt-document-text.ts
@@ -1,5 +1,8 @@
import type { JSONContent } from '@tiptap/react'
-import { splitImageReferences } from '../../../../shared/native-chat-image-reference'
+import {
+ imageReferenceToken,
+ splitImageReferences
+} from '../../../../shared/native-chat-image-reference'
import { NATIVE_CHAT_PASTED_TEXT_TOKEN } from './native-chat-pasted-text-reference'
export const IMAGE_REFERENCE_NODE = 'nativeChatImageReference'
@@ -92,3 +95,40 @@ export function appendPromptDocumentText(
}
return promptTextContent(after)
}
+
+/** Slice serialized prompt offsets while retaining selected nodes inside the range. */
+export function slicePromptDocument(document: JSONContent, from: number, to: number): JSONContent {
+ let offset = 0
+ const blocks: JSONContent[] = []
+ for (const [index, block] of (document.content ?? []).entries()) {
+ if (index > 0) {
+ offset++
+ }
+ const start = offset
+ const content: JSONContent[] = []
+ for (const node of block.content ?? []) {
+ const value =
+ node.type === IMAGE_REFERENCE_NODE
+ ? imageReferenceToken(String(node.attrs?.attachmentId))
+ : node.type === 'hardBreak'
+ ? '\n'
+ : (node.text ?? String(node.attrs?.token ?? ''))
+ const end = offset + value.length
+ if (end > from && offset < to) {
+ content.push(
+ node.type === 'text'
+ ? {
+ ...node,
+ text: value.slice(Math.max(0, from - offset), Math.min(value.length, to - offset))
+ }
+ : node
+ )
+ }
+ offset = end
+ }
+ if (offset >= from && start <= to) {
+ blocks.push({ ...block, content })
+ }
+ }
+ return { ...document, content: blocks.length ? blocks : [{ type: 'paragraph' }] }
+}
diff --git a/src/renderer/src/components/native-chat/native-chat-queued-edit-draft.test.ts b/src/renderer/src/components/native-chat/native-chat-queued-edit-draft.test.ts
new file mode 100644
index 000000000000..2d86cfae5f89
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-queued-edit-draft.test.ts
@@ -0,0 +1,206 @@
+import { structuredAgentSessionSendBody } from '../../../../shared/structured-agent-session-send-mutation'
+import { queuedMessageWithEdit } from '../../../../shared/queued-message-edit'
+import { promptTextContent } from './native-chat-prompt-document-text'
+// @vitest-environment happy-dom
+import { beforeEach, describe, expect, it } from 'vitest'
+import type { AgentJournalMessageItem } from '../../../../shared/agent-session-journal-types'
+import { imageReferenceToken } from '../../../../shared/native-chat-image-reference'
+import {
+ nativeChatPastedTextOperations,
+ nativeChatPastedTextToken
+} from './native-chat-pasted-text-reference'
+import { settleNativeChatPastedText } from './native-chat-composer-pasted-text-references'
+import { promptSkillRefs } from './native-chat-prompt-document'
+import {
+ clearNativeChatComposerDraftsForTests,
+ hydrateNativeChatComposerDrafts,
+ nativeChatComposerDraftWritesSettled,
+ readNativeChatComposerDraft,
+ updateNativeChatComposerDraft
+} from './native-chat-composer-draft-store'
+import {
+ createMemoryNativeChatComposerDraftStorage,
+ setNativeChatComposerDraftStorageForTests
+} from './native-chat-composer-draft-storage'
+import {
+ addNativeChatPendingAttachment,
+ clearNativeChatPendingAttachmentsForTests,
+ settleNativeChatPendingAttachment
+} from './native-chat-pending-attachment-cache'
+import {
+ createNativeChatQueuedEditDraft,
+ discardNativeChatQueuedEdit,
+ handBackNativeChatQueuedEdit,
+ nativeChatQueuedEditInput,
+ nativeChatQueuedEditDirty
+} from './native-chat-queued-edit-draft'
+
+const skill = { name: 'review', path: '/remote/skills/review/SKILL.md' }
+const body: AgentJournalMessageItem = {
+ kind: 'message',
+ role: 'user',
+ blocks: [
+ { type: 'text', text: '$review [Image #1]' },
+ { type: 'image-ref', url: 'https://host/original.png', alt: 'original' }
+ ],
+ skills: [skill]
+}
+let storage: ReturnType
+beforeEach(async () => {
+ clearNativeChatComposerDraftsForTests()
+ clearNativeChatPendingAttachmentsForTests()
+ nativeChatPastedTextOperations.clear()
+ localStorage.clear()
+ storage = createMemoryNativeChatComposerDraftStorage()
+ setNativeChatComposerDraftStorageForTests(storage)
+ await hydrateNativeChatComposerDrafts()
+})
+
+describe('queued edit draft ownership', () => {
+ it('seeds skill identity without a catalog, preserves original image blocks and omits unsupported fields', async () => {
+ const edit = createNativeChatQueuedEditDraft(body, 'bottom')
+ expect(nativeChatQueuedEditInput(edit, true, true)).toEqual({
+ text: '$review [Image #1]',
+ images: [body.blocks[1]],
+ skills: [skill]
+ })
+ expect(nativeChatQueuedEditInput(edit, false, false)).toEqual({ text: '$review [Image #1]' })
+ await nativeChatComposerDraftWritesSettled()
+ expect(storage.drafts.has(edit.scopeKey)).toBe(false)
+ discardNativeChatQueuedEdit(edit)
+ })
+
+ it('retains distinct selected paths for repeated same-name skill tokens', () => {
+ const skills = [skill, { ...skill, path: '/other/SKILL.md' }]
+ const edit = createNativeChatQueuedEditDraft(
+ {
+ ...body,
+ blocks: [{ type: 'text', text: '$review $review' }],
+ skills
+ },
+ 'bottom'
+ )
+ expect(nativeChatQueuedEditInput(edit, true, true).skills).toEqual(skills)
+ expect(nativeChatQueuedEditDirty(edit)).toBe(false)
+ discardNativeChatQueuedEdit(edit)
+ })
+
+ it.each(['review.v2', '検証.v2'])(
+ 'retains picker-valid %s identities without a catalog',
+ (name) => {
+ const skills = [
+ { name, path: '/remote/first/SKILL.md' },
+ { name, path: '/remote/second/SKILL.md' }
+ ]
+ const edit = createNativeChatQueuedEditDraft(
+ { ...body, blocks: [{ type: 'text', text: `$${name} $${name}` }], skills },
+ 'bottom'
+ )
+ expect(nativeChatQueuedEditDirty(edit)).toBe(false)
+ expect(nativeChatQueuedEditInput(edit, true, true).skills).toEqual(skills)
+ discardNativeChatQueuedEdit(edit)
+ }
+ )
+
+ it('normalizes repeated skill refs when deciding whether to hand a draft back', () => {
+ const repeated = { ...body, skills: [skill, skill] }
+ const edit = createNativeChatQueuedEditDraft(repeated, 'bottom')
+ expect(nativeChatQueuedEditDirty(edit)).toBe(false)
+ expect(handBackNativeChatQueuedEdit(edit)).toBe(false)
+ discardNativeChatQueuedEdit(edit)
+ })
+
+ it('returns a full draft after existing content, keeps skill/paste identity, and redirects late uploads', () => {
+ updateNativeChatComposerDraft(
+ 'bottom',
+ { text: 'already here', images: [{ id: 'bottom-image', path: '/bottom.png' }] },
+ 'immediate'
+ )
+ const edit = createNativeChatQueuedEditDraft(body, 'bottom')
+ const paste = 'd851d135-98ac-44d1-b122-caf190002345'
+ const pendingPaste = 'd851d135-98ac-44d1-b122-caf190002346'
+ const text = `$review changed ${nativeChatPastedTextToken(paste)} ${nativeChatPastedTextToken(pendingPaste)} ${imageReferenceToken('late-image')}`
+ const seeded = readNativeChatComposerDraft(edit.scopeKey).document
+ updateNativeChatComposerDraft(
+ edit.scopeKey,
+ {
+ text,
+ document: {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ seeded!.content![0]!.content![0]!,
+ ...(promptTextContent(text.slice('$review'.length)).content![0]!.content ?? [])
+ ]
+ }
+ ]
+ },
+ pastedTextFiles: { [paste]: '/remote/paste.txt' },
+ images: [...edit.initial.images, { id: 'added', path: '/remote/added.png' }]
+ },
+ 'immediate'
+ )
+ addNativeChatPendingAttachment(edit.scopeKey, { id: 'late-image', path: '', pending: true })
+ nativeChatPastedTextOperations.set(pendingPaste, {
+ scopeKey: edit.scopeKey,
+ text: 'large paste'
+ })
+ expect(handBackNativeChatQueuedEdit(edit)).toBe(true)
+ discardNativeChatQueuedEdit(edit)
+ expect(settleNativeChatPendingAttachment(edit.scopeKey, 'late-image', '/remote/late.png')).toBe(
+ true
+ )
+ expect(settleNativeChatPastedText(pendingPaste, '/remote/late.txt')).toBe(true)
+ const returned = readNativeChatComposerDraft('bottom')
+ expect(returned.text).toBe(`already here\n\n${text}`)
+ expect(promptSkillRefs(returned.document)).toEqual([skill])
+ expect(returned.pastedTextFiles).toEqual({
+ [paste]: '/remote/paste.txt',
+ [pendingPaste]: '/remote/late.txt'
+ })
+ expect(returned.images.map(({ id }) => id)).toEqual(['bottom-image', 'added', 'late-image'])
+ expect(returned.text).not.toContain(imageReferenceToken(edit.kept[0]!.id))
+ expect(readNativeChatComposerDraft(edit.scopeKey).text).toBe('')
+ })
+
+ it('Cancel prevents late operations from reviving a closed edit', () => {
+ const edit = createNativeChatQueuedEditDraft(body, 'bottom')
+ addNativeChatPendingAttachment(edit.scopeKey, { id: 'cancelled', path: '', pending: true })
+ discardNativeChatQueuedEdit(edit)
+ expect(settleNativeChatPendingAttachment(edit.scopeKey, 'cancelled', '/late.png')).toBe(false)
+ updateNativeChatComposerDraft(edit.scopeKey, { text: 'late text' }, 'immediate')
+ expect(readNativeChatComposerDraft(edit.scopeKey).text).toBe('')
+ expect(readNativeChatComposerDraft('bottom').text).toBe('')
+ })
+})
+
+it.each(['/', '$'] as const)(
+ 'reopens supplied %s identities through rich nodes, serializer and the next host body',
+ (sigil) => {
+ const skills = [
+ { name: 'model', path: '/A/SKILL.md' },
+ { name: 'model', path: '/B/SKILL.md' },
+ { name: 'skill:review.v2', path: '/pi/SKILL.md' },
+ { name: '审查', path: '/unicode/SKILL.md' }
+ ]
+ const text = `${sigil}model ${sigil}model\n${sigil}skill:review.v2 ${sigil}审查`
+ const queued: AgentJournalMessageItem = {
+ kind: 'message',
+ role: 'user',
+ blocks: [{ type: 'text', text }],
+ skills
+ }
+ const edit = createNativeChatQueuedEditDraft(queued, 'bottom')
+ expect(promptSkillRefs(readNativeChatComposerDraft(edit.scopeKey).document)).toEqual(skills)
+ const fields = nativeChatQueuedEditInput(edit, true, true)
+ expect(fields.skills).toEqual(skills)
+ expect(structuredAgentSessionSendBody(text, [], skills).skills).toEqual(
+ sigil === '$' ? skills : undefined
+ )
+ const result = queuedMessageWithEdit(queued, fields)
+ expect(result).toEqual({ ok: true, body: queued })
+ discardNativeChatQueuedEdit(edit)
+ }
+)
diff --git a/src/renderer/src/components/native-chat/native-chat-queued-edit-draft.ts b/src/renderer/src/components/native-chat/native-chat-queued-edit-draft.ts
new file mode 100644
index 000000000000..c22a01d7a1c9
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-queued-edit-draft.ts
@@ -0,0 +1,218 @@
+import type { JSONContent } from '@tiptap/react'
+import type { AgentJournalMessageItem } from '../../../../shared/agent-session-journal-types'
+import { imageReferenceToken } from '../../../../shared/native-chat-image-reference'
+import {
+ imageReferencesToPromptMarkers,
+ promptMarkersToImageReferences
+} from '../../../../shared/native-chat-image-transcript-markers'
+import {
+ queuedEditKeptImages,
+ queuedMessageEditableText,
+ type QueuedMessageEdit
+} from '../../../../shared/queued-message-edit'
+import { pickedSkillsInText } from '../../../../shared/structured-agent-session-send-mutation'
+import { createUuidV4 } from '../../../../shared/uuid-v4'
+import { promptSkillRefs } from './native-chat-prompt-document'
+import { promptTextContent, withoutImageReferenceNodes } from './native-chat-prompt-document-text'
+import {
+ appendToNativeChatComposerDraft,
+ redirectNativeChatEditDraft,
+ closeNativeChatEditDraft,
+ openNativeChatEditDraft,
+ readNativeChatComposerDraft
+} from './native-chat-composer-draft-store'
+import {
+ clearNativeChatPendingAttachments,
+ moveNativeChatPendingAttachments,
+ nativeChatPendingAttachmentSnapshot
+} from './native-chat-pending-attachment-cache'
+import {
+ nativeChatPastedTextOperations,
+ NATIVE_CHAT_PASTED_TEXT_TOKEN,
+ nativeChatPastedTextId
+} from './native-chat-pasted-text-reference'
+import {
+ nativeChatPastedTextAdmission,
+ resolveNativeChatPastedText
+} from './native-chat-composer-pasted-text-references'
+import { nativeChatQueuedEditValueKey, sameQueuedEditImage } from './native-chat-queued-edit-value'
+import type {
+ NativeChatComposerDraft,
+ NativeChatComposerDraftImage
+} from './native-chat-composer-draft-storage'
+
+function seedSkills(
+ node: JSONContent,
+ body: AgentJournalMessageItem,
+ occurrences = new Map()
+): JSONContent {
+ if (node.type === 'text' && node.text) {
+ return node
+ }
+ return {
+ ...node,
+ content: node.content?.flatMap((child) => {
+ if (child.type !== 'text' || !child.text) {
+ return [seedSkills(child, body, occurrences)]
+ }
+ const pieces: JSONContent[] = []
+ const text = child.text
+ let start = 0
+ const names = [...new Set(body.skills?.map(({ name }) => name) ?? [])]
+ .sort((a, b) => b.length - a.length)
+ .map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
+ const pattern = new RegExp(`[$/](?:${names.join('|')})(?![\\w:-])`, 'gu')
+ for (const match of text.matchAll(pattern)) {
+ if (match[0].startsWith('/') && match.index > 0 && !/\s/u.test(text[match.index - 1])) {
+ continue
+ }
+ const name = match[0].slice(1)
+ const matching = body.skills?.filter((skill) => skill.name === name) ?? []
+ const occurrence = occurrences.get(name) ?? 0
+ const skill = matching[occurrence] ?? matching[0]
+ occurrences.set(name, occurrence + 1)
+ if (!skill) {
+ continue
+ }
+ if (match.index > start) {
+ pieces.push({ type: 'text', text: text.slice(start, match.index) })
+ }
+ pieces.push({ type: 'nativeChatSkill', attrs: { token: match[0], path: skill.path } })
+ start = match.index + match[0].length
+ }
+ if (start < text.length) {
+ pieces.push({ type: 'text', text: text.slice(start) })
+ }
+ return pieces
+ })
+ }
+}
+
+export type NativeChatQueuedEditDraft = {
+ scopeKey: string
+ destination: string
+ body: AgentJournalMessageItem
+ baseKey: string
+ initial: NativeChatComposerDraft
+ kept: (ReturnType[number] & {
+ image: NativeChatComposerDraftImage
+ })[]
+}
+
+export function createNativeChatQueuedEditDraft(
+ body: AgentJournalMessageItem,
+ destination: string
+): NativeChatQueuedEditDraft {
+ const scopeKey = `queued-edit:${createUuidV4()}`
+ const kept = queuedEditKeptImages(body)
+ const text = promptMarkersToImageReferences(
+ queuedMessageEditableText(body) ?? '',
+ kept.map(({ id }) => id)
+ )
+ const initial: NativeChatComposerDraft = {
+ text,
+ document: seedSkills(promptTextContent(text), body),
+ images: kept.map(({ id, block }) => ({ id, path: block.path ?? block.url ?? '' }))
+ }
+ openNativeChatEditDraft(scopeKey, initial)
+ return {
+ scopeKey,
+ destination,
+ body,
+ baseKey: nativeChatQueuedEditValueKey(scopeKey),
+ initial,
+ kept: kept.map((entry, index) => ({ ...entry, image: initial.images[index] }))
+ }
+}
+
+export function nativeChatQueuedEditInput(
+ edit: NativeChatQueuedEditDraft,
+ imagesCapable: boolean,
+ skillsCapable: boolean
+): QueuedMessageEdit {
+ const draft = readNativeChatComposerDraft(edit.scopeKey)
+ const text = imageReferencesToPromptMarkers(
+ resolveNativeChatPastedText(edit.scopeKey, draft.text),
+ draft.images.map(({ id }) => id)
+ )
+ const images = draft.images.map(
+ (image) =>
+ edit.kept.find((entry) => sameQueuedEditImage(entry.image, image))?.block ?? {
+ type: 'image-ref' as const,
+ path: image.path
+ }
+ )
+ return {
+ text,
+ ...(imagesCapable ? { images } : {}),
+ ...(skillsCapable
+ ? { skills: pickedSkillsInText(text, promptSkillRefs(draft.document), ['$', '/']) }
+ : {})
+ }
+}
+
+export function nativeChatQueuedEditDirty(edit: NativeChatQueuedEditDraft): boolean {
+ return nativeChatQueuedEditValueKey(edit.scopeKey) !== edit.baseKey
+}
+
+export function nativeChatQueuedEditHeld(
+ edit: NativeChatQueuedEditDraft,
+ imagesCapable: boolean
+): boolean {
+ const draft = readNativeChatComposerDraft(edit.scopeKey)
+ return (
+ nativeChatPendingAttachmentSnapshot(edit.scopeKey).some(({ pending }) => pending) ||
+ nativeChatPastedTextAdmission(edit.scopeKey) !== null ||
+ (!imagesCapable && JSON.stringify(draft.images) !== JSON.stringify(edit.initial.images)) ||
+ draft.images.some(({ unavailableName }) => unavailableName !== undefined)
+ )
+}
+
+/** Move only draft-owned additions; card-owned images remain with the accepted message. */
+export function handBackNativeChatQueuedEdit(
+ edit: NativeChatQueuedEditDraft,
+ force = false
+): boolean {
+ const draft = readNativeChatComposerDraft(edit.scopeKey)
+ let text = draft.text
+ let document = draft.document
+ const kept = edit.kept.filter((entry) =>
+ draft.images.some((image) => sameQueuedEditImage(entry.image, image))
+ )
+ for (const { id } of kept) {
+ text = text.replaceAll(imageReferenceToken(id), '')
+ if (document) {
+ document = withoutImageReferenceNodes(document, id)
+ }
+ }
+ const dirty = force || nativeChatQueuedEditDirty(edit)
+ if (!dirty) {
+ text = ''
+ document = undefined
+ }
+ const images = draft.images.filter(
+ (image) => !kept.some((entry) => sameQueuedEditImage(entry.image, image))
+ )
+ const pending = nativeChatPendingAttachmentSnapshot(edit.scopeKey)
+ appendToNativeChatComposerDraft(edit.destination, {
+ text,
+ document,
+ images,
+ pastedTextFiles: draft.pastedTextFiles
+ })
+ redirectNativeChatEditDraft(edit.scopeKey, edit.destination)
+ moveNativeChatPendingAttachments(edit.scopeKey, edit.destination)
+ for (const token of draft.text.split(NATIVE_CHAT_PASTED_TEXT_TOKEN)) {
+ const id = nativeChatPastedTextId(token)
+ const operation = id ? nativeChatPastedTextOperations.get(id) : undefined
+ if (operation?.scopeKey === edit.scopeKey) {
+ operation.scopeKey = edit.destination
+ }
+ }
+ return text !== '' || images.length > 0 || pending.length > 0
+}
+
+export function discardNativeChatQueuedEdit(edit: NativeChatQueuedEditDraft): void {
+ clearNativeChatPendingAttachments(edit.scopeKey)
+ closeNativeChatEditDraft(edit.scopeKey)
+}
diff --git a/src/renderer/src/components/native-chat/native-chat-queued-edit-outcome.ts b/src/renderer/src/components/native-chat/native-chat-queued-edit-outcome.ts
new file mode 100644
index 000000000000..01cfd121125a
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-queued-edit-outcome.ts
@@ -0,0 +1,112 @@
+import type { AgentSessionQueuedMessageUpdateResult } from '../../../../shared/agent-session-wire'
+import { agentSessionSendBodyFingerprint } from '../../../../shared/structured-agent-session-send-mutation'
+import type { StructuredAgentSessionWriteOutcome } from './use-structured-agent-session-mutate'
+import type {
+ NativeChatQueuedEditSession,
+ NativeChatQueuedEditAttempt
+} from './native-chat-queued-edit-session'
+import { queuedEditStanding } from '../../../../shared/queued-message-edit'
+import { nativeChatQueuedEditValueKey } from './native-chat-queued-edit-value'
+
+type Outcome = 'close' | 'rebase' | 'gone' | 'changed' | 'refused' | 'stay'
+
+/** Replies and publications settle only the current immutable attempt. */
+export function settleNativeChatQueuedEdit(
+ edit: NativeChatQueuedEditSession,
+ attempt = edit.attempt,
+ result?: StructuredAgentSessionWriteOutcome
+): Outcome {
+ if (edit.closed || edit.attempt !== attempt) {
+ return 'stay'
+ }
+ const loss =
+ edit.leaseLoss?.lease === edit.lease && edit.leaseLoss.expected === edit.baseFingerprint
+ ? edit.leaseLoss.status
+ : undefined
+ const lost = loss === 'changed' ? 'changed' : loss ? 'gone' : undefined
+ const standing = queuedEditStanding(
+ edit.view,
+ edit.sessionId,
+ edit.messageId,
+ attempt?.fingerprint ?? null
+ )
+ let proof = !result && (standing === 'holds' || standing === 'sent')
+ if (result?.kind === 'done') {
+ const status = result.value.status
+ if (status === 'gone' || status === 'changed') {
+ edit.attempt = undefined
+ return status
+ }
+ if (status !== 'updated' && status !== 'unchanged') {
+ edit.attempt = undefined
+ return lost ?? (edit.pendingClose || standing === 'gone' ? 'gone' : 'refused')
+ }
+ proof = result.value.fingerprint === attempt?.fingerprint
+ if (!proof && attempt) {
+ attempt.phase = 'unknown'
+ edit.uncertain ||= attempt.fingerprint !== attempt.expected
+ }
+ } else if (result) {
+ if (result.kind === 'dropped' || result.failure.kind !== 'unconfirmed') {
+ edit.attempt = undefined
+ return lost ?? (edit.pendingClose || standing === 'gone' ? 'gone' : 'refused')
+ }
+ if (attempt) {
+ attempt.phase = 'unknown'
+ edit.uncertain ||= attempt.fingerprint !== attempt.expected
+ }
+ proof = standing === 'holds' || standing === 'sent'
+ }
+ if (attempt && proof) {
+ // An unchanged observation cannot exclude an older divergent CAS write from the same base.
+ if (edit.uncertain && attempt.fingerprint === edit.baseFingerprint) {
+ attempt.phase = 'unknown'
+ return lost ?? (edit.pendingClose || standing === 'sent' ? 'gone' : 'stay')
+ }
+ const equal = nativeChatQueuedEditValueKey(edit.scopeKey) === attempt.key
+ rebaseNativeChatQueuedEdit(edit, attempt)
+ if (equal) {
+ return 'close'
+ }
+ return edit.pendingClose ||
+ standing === 'sent' ||
+ standing === 'gone' ||
+ loss === 'gone' ||
+ loss === 'not-editable'
+ ? 'gone'
+ : loss === 'changed' && attempt.fingerprint === attempt.expected
+ ? 'changed'
+ : 'rebase'
+ }
+ if (lost) {
+ return lost
+ }
+ if (standing === 'gone' || edit.pendingClose) {
+ return 'gone'
+ }
+ const listed = edit.view.messages?.find((entry) => entry.messageId === edit.messageId)
+ if (
+ !result &&
+ listed &&
+ agentSessionSendBodyFingerprint(edit.sessionId, listed.body) !== edit.baseFingerprint
+ ) {
+ return 'changed'
+ }
+ return 'stay'
+}
+
+function rebaseNativeChatQueuedEdit(
+ edit: NativeChatQueuedEditSession,
+ attempt: NativeChatQueuedEditAttempt
+): void {
+ const blocks = attempt.body.blocks.filter((block) => block.type === 'image-ref')
+ edit.kept = attempt.draft.images.flatMap((image, index) =>
+ blocks[index] ? [{ id: image.id, image, block: blocks[index] }] : []
+ )
+ edit.baseKey = attempt.key
+ edit.initial = attempt.draft
+ edit.body = attempt.body
+ edit.baseFingerprint = attempt.fingerprint
+ edit.uncertain = false
+ edit.attempt = undefined
+}
diff --git a/src/renderer/src/components/native-chat/native-chat-queued-edit-session.ts b/src/renderer/src/components/native-chat/native-chat-queued-edit-session.ts
new file mode 100644
index 000000000000..661b137fb309
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-queued-edit-session.ts
@@ -0,0 +1,210 @@
+import type { QueuedEditLeaseLoss } from '../../../../shared/queued-message-edit-lease'
+import {
+ readNativeChatComposerDraft,
+ updateNativeChatComposerDraft,
+ subscribeToNativeChatComposerDraft
+} from './native-chat-composer-draft-store'
+import { queuedMessageWithEdit } from '../../../../shared/queued-message-edit'
+import {
+ nativeChatQueuedEditInput,
+ nativeChatQueuedEditHeld,
+ handBackNativeChatQueuedEdit,
+ type NativeChatQueuedEditDraft
+} from './native-chat-queued-edit-draft'
+import type { NativeChatComposerDraft } from './native-chat-composer-draft-storage'
+import { promptMarkersToImageReferences } from '../../../../shared/native-chat-image-transcript-markers'
+import { nativeChatImageRowIds } from './native-chat-composer-image-references'
+import { useEffect } from 'react'
+import { toast } from 'sonner'
+import { translate } from '@/i18n/i18n'
+import { subscribeToNativeChatPendingAttachments } from './native-chat-pending-attachment-cache'
+import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
+import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire'
+import type {
+ AgentJournalMessageItem,
+ AgentJournalSubmission
+} from '../../../../shared/agent-session-journal-types'
+import type { QueuedEditLease } from './structured-agent-session-queued-edit-lease'
+import type { QueuedMessageCard } from '../../../../shared/structured-agent-session-queued-cards'
+import type { StructuredAgentSessionWrite } from './use-structured-agent-session-mutate'
+
+export type QueuedEditTransport = {
+ target: RuntimeClientTarget
+ sessionId: string
+ /** The host advertises `agent-session.queued-message-edit.v1`; without it there is no Edit. */
+ capable: boolean
+ /** The host advertises `agent-session.queued-message-image-edit.v1`: Save may replace images. */
+ imagesCapable?: boolean
+ skillsCapable?: boolean
+ reorderCapable?: boolean
+ write: StructuredAgentSessionWrite
+}
+
+export type QueuedMessageInlineEditor = {
+ messageId: string
+ draftScopeKey: string
+ text: string
+ change: (text: string) => void
+ acquiring: boolean
+ saving: boolean
+ canSave: boolean
+ imagesCapable: boolean
+ save: () => void
+ cancel: () => void
+}
+
+export type NativeChatQueuedEditAttempt = {
+ phase: 'pending' | 'unknown'
+ fingerprint: string
+ expected: string
+ body: AgentJournalMessageItem
+ draft: NativeChatComposerDraft
+ key: string
+}
+export function nativeChatQueuedEditSaving(edit: NativeChatQueuedEditSession): boolean {
+ return edit.attempt?.phase === 'pending'
+}
+
+export type NativeChatQueuedEditSession = NativeChatQueuedEditDraft & {
+ scope: string
+ sessionId: string
+ messageId: string
+ /** The body Save expects to replace; fixed for the edit, so a newer text is never overwritten. */
+ baseFingerprint: string
+ acquiring: boolean
+ attempt?: NativeChatQueuedEditAttempt
+ uncertain: boolean
+ leaseLoss?: QueuedEditLeaseLoss
+ closed: boolean
+ pendingClose: boolean
+ view: {
+ messages: readonly AgentSessionQueuedMessage[] | null
+ cards: readonly QueuedMessageCard[]
+ submissions: readonly AgentJournalSubmission[]
+ promptOpen: boolean
+ }
+ lease: QueuedEditLease
+}
+
+export function useNativeChatQueuedEditLifetime({
+ scope,
+ shownScope,
+ active,
+ reopen,
+ close,
+ render
+}: {
+ scope: string
+ shownScope: string | undefined
+ active: { current: NativeChatQueuedEditSession | null }
+ reopen: { current: { scope: string; messageId: string; stale: string } | null }
+ close: (edit?: NativeChatQueuedEditSession | null) => void
+ render: (edit: NativeChatQueuedEditSession | null) => void
+}) {
+ // On chat/host/composer changes, unmount or reload, hand unsaved typing back to its original chat.
+ useEffect(() => {
+ const handOff = (): void => {
+ const edit = active.current
+ active.current = null
+ reopen.current = null
+ if (!edit) {
+ return
+ }
+ if (nativeChatQueuedEditSaving(edit)) {
+ edit.pendingClose = true
+ return
+ }
+ if (handBackNativeChatQueuedEdit(edit, edit.uncertain)) {
+ toast.error(
+ translate(
+ 'components.native-chat.queuedMessages.editReturned',
+ 'Your edit is in the chat box.'
+ )
+ )
+ }
+ close(edit)
+ }
+ window.addEventListener('pagehide', handOff)
+ return () => {
+ window.removeEventListener('pagehide', handOff)
+ handOff()
+ }
+ }, [scope, close, active, reopen])
+
+ useEffect(() => {
+ if (!shownScope) {
+ return
+ }
+ const update = (): void => {
+ if (active.current) {
+ render(active.current)
+ }
+ }
+ const unsubscribe = subscribeToNativeChatComposerDraft(shownScope, update)
+ const unpending = subscribeToNativeChatPendingAttachments(shownScope, update)
+ return () => {
+ unsubscribe()
+ unpending()
+ }
+ }, [shownScope, render, active])
+}
+
+export function changeNativeChatQueuedEditText(
+ edit: NativeChatQueuedEditSession,
+ text: string
+): void {
+ if (!edit.acquiring && !nativeChatQueuedEditSaving(edit)) {
+ updateNativeChatComposerDraft(
+ edit.scopeKey,
+ {
+ text: promptMarkersToImageReferences(text, nativeChatImageRowIds(edit.scopeKey)),
+ document: undefined
+ },
+ 'immediate'
+ )
+ }
+}
+
+export type StructuredQueuedEditArgs = {
+ transport: QueuedEditTransport
+ /** The host's published cards, for each card's full body. */
+ messages: readonly AgentSessionQueuedMessage[] | null
+ /** The cards this pane shows; a card that leaves them has left the queue. */
+ cards: readonly QueuedMessageCard[]
+ submissions: readonly AgentJournalSubmission[]
+ composerScopeKey: string | undefined
+ /** A question or approval holds the chat box's place. */
+ promptOpen: boolean
+}
+
+export function nativeChatQueuedEditEditor(
+ shown: NativeChatQueuedEditSession | null,
+ scope: string,
+ imagesCapable: boolean,
+ skillsCapable: boolean,
+ save: () => void,
+ cancel: () => void
+): QueuedMessageInlineEditor | undefined {
+ const typed =
+ shown &&
+ queuedMessageWithEdit(
+ shown.body,
+ nativeChatQueuedEditInput(shown, imagesCapable, skillsCapable)
+ )
+ const editor: QueuedMessageInlineEditor | undefined =
+ shown && typed && shown.scope === scope
+ ? {
+ messageId: shown.messageId,
+ draftScopeKey: shown.scopeKey,
+ text: readNativeChatComposerDraft(shown.scopeKey).text,
+ change: (text) => changeNativeChatQueuedEditText(shown, text),
+ imagesCapable,
+ acquiring: shown.acquiring,
+ saving: nativeChatQueuedEditSaving(shown),
+ canSave: typed.ok && !nativeChatQueuedEditHeld(shown, imagesCapable),
+ save,
+ cancel
+ }
+ : undefined
+ return editor
+}
diff --git a/src/renderer/src/components/native-chat/native-chat-queued-edit-value.ts b/src/renderer/src/components/native-chat/native-chat-queued-edit-value.ts
new file mode 100644
index 000000000000..65dfa4278528
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-queued-edit-value.ts
@@ -0,0 +1,39 @@
+import type {
+ NativeChatComposerDraft,
+ NativeChatComposerDraftImage
+} from './native-chat-composer-draft-storage'
+import { readNativeChatComposerDraft } from './native-chat-composer-draft-store'
+import { nativeChatPendingAttachmentSnapshot } from './native-chat-pending-attachment-cache'
+import { promptTextContent } from './native-chat-prompt-document-text'
+import { nativeChatPastedTextToken } from './native-chat-pasted-text-reference'
+import { resolveNativeChatPastedText } from './native-chat-composer-pasted-text-references'
+
+export function sameQueuedEditImage(
+ left: NativeChatComposerDraftImage,
+ right: NativeChatComposerDraftImage
+): boolean {
+ return (
+ left.id === right.id &&
+ left.path === right.path &&
+ left.connectionId === right.connectionId &&
+ left.unavailableName === right.unavailableName
+ )
+}
+
+/** Compare complete local intent independently of host capability omissions. */
+export function nativeChatQueuedEditValueKey(
+ scope: string,
+ draft: NativeChatComposerDraft = readNativeChatComposerDraft(scope),
+ pending = nativeChatPendingAttachmentSnapshot(scope)
+): string {
+ return JSON.stringify([
+ draft.text,
+ draft.document ?? promptTextContent(draft.text),
+ draft.images,
+ Object.entries(draft.pastedTextFiles ?? {})
+ .filter(([id]) => draft.text.includes(nativeChatPastedTextToken(id)))
+ .sort(([a], [b]) => a.localeCompare(b)),
+ resolveNativeChatPastedText(scope, draft.text),
+ pending
+ ])
+}
diff --git a/src/renderer/src/components/native-chat/queued-edit-trigger-scaling.test.tsx b/src/renderer/src/components/native-chat/queued-edit-trigger-scaling.test.tsx
new file mode 100644
index 000000000000..579fb344752b
--- /dev/null
+++ b/src/renderer/src/components/native-chat/queued-edit-trigger-scaling.test.tsx
@@ -0,0 +1,153 @@
+// @vitest-environment happy-dom
+import { harness, begin, message, script, withText } from './structured-queued-edit.test-fixture'
+import { countedQueuedEditPublication } from '../../../../shared/queued-edit-publication.test-fixture'
+import * as publication from '../../../../shared/queued-message-edit-publication'
+import * as eligibility from '../../../../shared/queued-message-edit-command'
+import { act, renderHook, waitFor } from '@testing-library/react'
+import { useRef } from 'react'
+import { afterEach, expect, it, vi } from 'vitest'
+import { useNativeChatComposerCommandPicker } from './use-native-chat-composer-command-picker'
+import type { NativeChatComposerInput } from './native-chat-composer-input'
+import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types'
+import type { AgentType } from '../../../../shared/agent-status-types'
+vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
+vi.mock('./use-native-chat-skills', () => ({
+ useNativeChatSkills: () => ({ status: 'ready', skills: [], retry: () => {} })
+}))
+afterEach(() => vi.restoreAllMocks())
+it.each([10, 100])(
+ 'actual edit hook bounds publication-key reads with %i fresh Q/C/R rows',
+ async (n) => {
+ const counter = countedQueuedEditPublication()
+ vi.spyOn(publication, 'queuedEditPublicationKey').mockImplementation(counter.key)
+ const rows = () => [
+ ...Array.from({ length: n - 1 }, (_, i) => ({ ...message, messageId: `other-${i}` })),
+ message
+ ]
+ const submissions = () =>
+ Array.from({ length: n }, (_, i) => ({
+ clientMessageId: `s${i}`,
+ queuedMessageId: `unused-${i}`,
+ fence: 1,
+ payloadFingerprint: 'other',
+ dispatchState: 'pending' as const,
+ providerItemId: null,
+ reason: null,
+ submittedAt: 1,
+ resolvedAt: null
+ }))
+ const hook = harness(true, { messages: rows(), submissions: submissions() })
+ hook.rerender({ messages: rows(), submissions: submissions() })
+ expect(counter.reads).toBe(0)
+ await begin(hook)
+ counter.reset()
+ hook.rerender({ messages: rows(), submissions: submissions() })
+ expect(counter.reads).toBeGreaterThanOrEqual(3 * n)
+ expect(counter.reads).toBeLessThanOrEqual(3 * n + 2)
+ expect(hook.result.current.editor).toBeDefined()
+ act(() => hook.result.current.editor!.change('saved'))
+ script.answer = async () => ({
+ kind: 'not-done',
+ notice: 'unknown',
+ failure: { kind: 'unconfirmed' }
+ })
+ act(() => hook.result.current.editor!.save())
+ await waitFor(() => expect(hook.result.current.editor?.saving).toBe(false))
+ counter.reset()
+ hook.rerender({
+ messages: [...rows().slice(0, -1), withText('saved')],
+ submissions: submissions()
+ })
+ expect(counter.reads).toBeLessThanOrEqual(3 * n + 2)
+ expect(hook.result.current.editor).toBeUndefined()
+ }
+)
+it.each([10, 100])(
+ 'actual desktop menu prepares eligibility once per generation for %i reported rows',
+ (n) => {
+ const prepare = vi.spyOn(eligibility, 'prepareQueuedMessageEditText')
+ const reported = () =>
+ Array.from({ length: n }, (_, i) => ({ name: `provider-${i}`, kind: 'command' as const }))
+ const transport: NativeChatStructuredComposerTransport = {
+ capturePresentation: () => () => true,
+ send: () => true,
+ dispatchCommand: async () => ({ handled: false, accepted: false, error: null }),
+ optionsSurface: {
+ getSnapshot: () => [],
+ setOption: async () => ({ snapshot: [] }),
+ invokeAction: async () => ({ snapshot: [] }),
+ subscribe: () => () => {}
+ },
+ optionSnapshot: [],
+ onError: () => {},
+ runtime: 'local',
+ sessionId: 's',
+ worktreeId: 'wt',
+ runtimeEnvironmentId: null,
+ sessionCommands: reported()
+ }
+ const hook = renderHook(
+ ({
+ draft,
+ agent,
+ editing,
+ target
+ }: {
+ draft: string
+ agent: AgentType
+ editing: boolean
+ target: typeof transport
+ }) => {
+ const textareaRef = useRef(null)
+ return useNativeChatComposerCommandPicker({
+ agent,
+ terminalTabId: 'tab',
+ draftScopeKey: 'scope',
+ draft,
+ caret: draft.length,
+ recalledFromHistory: false,
+ disabled: false,
+ isComposing: () => false,
+ editor: {
+ textareaRef,
+ setDraft: () => {},
+ setCaret: () => {},
+ setActiveSuggestion: () => {}
+ },
+ structuredTransport: target,
+ editing,
+ setNotice: () => {}
+ })
+ },
+ {
+ initialProps: { draft: '', agent: 'claude', editing: true, target: transport }
+ }
+ )
+ expect(prepare).toHaveBeenCalledTimes(1)
+ for (const draft of ['/', '/p', '/provider', '/provider-9']) {
+ hook.rerender({ draft, agent: 'claude', editing: true, target: transport })
+ }
+ expect(prepare).toHaveBeenCalledTimes(1)
+ hook.rerender({ draft: '/', agent: 'claude', editing: false, target: transport })
+ expect(prepare).toHaveBeenCalledTimes(1)
+ hook.rerender({
+ draft: '/',
+ agent: 'claude',
+ editing: true,
+ target: { ...transport, sessionCommands: reported() }
+ })
+ expect(prepare).toHaveBeenCalledTimes(2)
+ const goal = { ...transport, threadGoal: { setObjective: async () => true } }
+ hook.rerender({ draft: '/', agent: 'claude', editing: true, target: goal })
+ expect(prepare).toHaveBeenCalledTimes(3)
+ hook.rerender({
+ draft: '/',
+ agent: 'claude',
+ editing: true,
+ target: { ...goal, threadGoal: { setObjective: async () => false } }
+ })
+ expect(prepare).toHaveBeenCalledTimes(3)
+ hook.rerender({ draft: '/', agent: 'codex', editing: true, target: goal })
+ expect(prepare).toHaveBeenCalledTimes(4)
+ }
+)
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-images.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-images.ts
deleted file mode 100644
index d3fcaa1f0459..000000000000
--- a/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-images.ts
+++ /dev/null
@@ -1,174 +0,0 @@
-// Keep original card image blocks verbatim; edit-only additions live in memory and must not share the durable chat draft scope.
-
-import type { AgentJournalMessageItem } from '../../../../shared/agent-session-journal-types'
-import {
- queuedEditHandOffTyping,
- queuedEditImagesPending,
- queuedEditKeptImages,
- queuedEditTextForKeptImages,
- type QueuedMessageEditResult,
- type QueuedMessageImageBlock
-} from '../../../../shared/queued-message-edit'
-import { createUuidV4 } from '../../../../shared/uuid-v4'
-import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
-import { appendNativeChatDraftCache } from './native-chat-draft-cache'
-import { isNativeChatImageAttachmentPath } from './native-chat-image-paste'
-import { imageReferenceInsertion } from '../../../../shared/native-chat-image-reference'
-
-export type QueuedEditImageState = {
- /** The chat box this edit hands off to; the one it started in, never another. */
- draftKey: string
- body: AgentJournalMessageItem
- text: string
- kept: readonly { id: string; block: QueuedMessageImageBlock }[]
- added: readonly NativeChatComposerImageAttachment[]
- intake: QueuedEditImageIntake
-}
-
-/** Bind intake to one edit so late upload results cannot attach to another. */
-export type QueuedEditImageIntake = {
- begin: (previewUrl?: string, pendingName?: string) => string | null
- resolve: (id: string, path: string, connectionId?: string | null) => boolean
- reveal: (id: string, previewUrl?: string) => void
- /** False once the chip settled or the person removed it, so its file must not attach. */
- drop: (id: string) => boolean
- /** Adds the image paths settled; returns the rest, which are referenced in the text. */
- append: (paths: string[], connectionId?: string | null) => string[]
- remove: (id: string) => void
-}
-
-/** What the editor draws and acts on; absent against a host that edits text only. */
-export type QueuedEditImagesEditor = {
- chips: NativeChatComposerImageAttachment[]
- intake: QueuedEditImageIntake
- /** Nothing to save: no text and no image, not even one still uploading. */
- empty: boolean
-}
-
-export function queuedEditImageFields(
- body: AgentJournalMessageItem,
- edit: () => QueuedEditImageState,
- live: () => boolean,
- render: () => void
-): Pick {
- return {
- kept: queuedEditKeptImages(body),
- added: [],
- intake: queuedEditImageIntake(edit, live, render)
- }
-}
-
-export function queuedEditImagesEditor(
- edit: QueuedEditImageState,
- result: QueuedMessageEditResult
-): QueuedEditImagesEditor {
- return {
- chips: [
- ...edit.kept.map(({ id, block }) => ({ id, path: block.path ?? block.url ?? '' })),
- ...edit.added
- ],
- intake: edit.intake,
- empty: !result.ok && result.reason === 'empty' && !queuedEditImagesPending(edit)
- }
-}
-
-function settled(image: NativeChatComposerImageAttachment): NativeChatComposerImageAttachment {
- return {
- id: image.id,
- path: image.path,
- ...(image.connectionId ? { connectionId: image.connectionId } : {})
- }
-}
-
-function releasePreview(image: NativeChatComposerImageAttachment): void {
- if (image.previewUrl?.startsWith('blob:')) {
- URL.revokeObjectURL(image.previewUrl)
- }
-}
-
-export function releaseQueuedEditImages(edit: QueuedEditImageState): void {
- edit.added.forEach(releasePreview)
-}
-
-/** Hand back unsaved text and settled additions atomically, excluding card-owned image markers; discard pending uploads with their edit, as mobile does. */
-export function handOffQueuedEdit(edit: QueuedEditImageState): boolean {
- const typing = queuedEditHandOffTyping(edit.body, edit)
- const images = edit.added.flatMap((image) => (image.pending ? [] : [settled(image)]))
- const text =
- typing +
- imageReferenceInsertion(
- typing,
- '',
- images.map(({ id }) => id)
- )
- appendNativeChatDraftCache(edit.draftKey, text, undefined, images)
- return text !== '' || images.length > 0
-}
-
-function queuedEditImageIntake(
- edit: () => QueuedEditImageState,
- live: () => boolean,
- render: () => void
-): QueuedEditImageIntake {
- const update = (added: QueuedEditImageState['added']): void => {
- edit().added = added
- if (live()) {
- render()
- }
- }
- const pendingChip = (id: string) => edit().added.find((image) => image.id === id && image.pending)
- return {
- begin: (previewUrl, pendingName) => {
- if (!live()) {
- return null
- }
- const id = createUuidV4()
- const chip = { id, path: '', pending: true, ...(previewUrl ? { previewUrl } : {}) }
- update([...edit().added, pendingName ? { ...chip, pendingName } : chip])
- return id
- },
- resolve: (id, path, connectionId) => {
- const chip = pendingChip(id)
- if (!chip || !live()) {
- return false
- }
- const image = settled({ id, path, ...(connectionId ? { connectionId } : {}) })
- const previewUrl = chip.previewUrl ? { previewUrl: chip.previewUrl } : {}
- update(edit().added.map((entry) => (entry === chip ? { ...image, ...previewUrl } : entry)))
- return true
- },
- reveal: (id, previewUrl) => {
- if (previewUrl && pendingChip(id)) {
- update(edit().added.map((entry) => (entry.id === id ? { ...entry, previewUrl } : entry)))
- }
- },
- drop: (id) => {
- const chip = pendingChip(id)
- if (chip) {
- releasePreview(chip)
- update(edit().added.filter((entry) => entry !== chip))
- }
- return chip !== undefined
- },
- append: (paths, connectionId) => {
- const images = paths.filter(isNativeChatImageAttachmentPath)
- if (live() && images.length > 0) {
- const link = connectionId ? { connectionId } : {}
- update([...edit().added, ...images.map((path) => ({ id: createUuidV4(), path, ...link }))])
- }
- return paths.filter((path) => !isNativeChatImageAttachmentPath(path))
- },
- remove: (id) => {
- const current = edit()
- current.added.filter((entry) => entry.id === id).forEach(releasePreview)
- const kept = current.kept.filter((entry) => entry.id !== id)
- if (kept.length !== current.kept.length) {
- // The field shows what the agent will read: the image's markers go, later ones renumber.
- const ids = (entries: QueuedEditImageState['kept']) => entries.map((entry) => entry.id)
- current.text = queuedEditTextForKeptImages(current.text, ids(current.kept), ids(kept))
- current.kept = kept
- }
- update(current.added.filter((entry) => entry.id !== id))
- }
- }
-}
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-lease.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-lease.ts
index e1eb188b087f..703658410e5a 100644
--- a/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-lease.ts
+++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-lease.ts
@@ -1,6 +1,9 @@
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
-import { startQueuedEditLease as startSharedQueuedEditLease } from '../../../../shared/queued-message-edit-lease'
+import {
+ startQueuedEditLease as startSharedQueuedEditLease,
+ type QueuedEditLeaseLoss
+} from '../../../../shared/queued-message-edit-lease'
export { QUEUED_EDIT_LEASE_RENEW_MS } from '../../../../shared/queued-message-edit-lease'
export type { QueuedEditLease } from '../../../../shared/queued-message-edit-lease'
@@ -11,12 +14,14 @@ export function startQueuedEditLease(input: {
messageId: string
editId: string
expectedBodyFingerprint: string
+ onLost: (loss: QueuedEditLeaseLoss) => void
}) {
return startSharedQueuedEditLease({
sessionId: input.sessionId,
messageId: input.messageId,
editId: input.editId,
expectedBodyFingerprint: input.expectedBodyFingerprint,
+ onLost: input.onLost,
request: (params) =>
callStructuredAgentSession(input.target, 'agentSession.queuedMessageEditHold', params)
})
diff --git a/src/renderer/src/components/native-chat/structured-queued-edit.test-fixture.tsx b/src/renderer/src/components/native-chat/structured-queued-edit.test-fixture.tsx
new file mode 100644
index 000000000000..0ab3102eaf72
--- /dev/null
+++ b/src/renderer/src/components/native-chat/structured-queued-edit.test-fixture.tsx
@@ -0,0 +1,164 @@
+import { QueuedMessageUpdateParams } from '../../../../shared/rpc-contract/structured-agent-session-queued-edit-params'
+import { queuedMessageWithEdit } from '../../../../shared/queued-message-edit'
+// @vitest-environment happy-dom
+
+// Exercise real edit lifecycle with a scripted host: preserve dirty text on departure and keep lease failures from blocking typing/Save/Cancel.
+
+import { act, cleanup, renderHook } from '@testing-library/react'
+import { afterEach, beforeEach, vi } from 'vitest'
+import { toast } from 'sonner'
+import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
+import type {
+ AgentSessionQueuedMessage,
+ AgentSessionQueuedMessageEditHoldParams,
+ AgentSessionQueuedMessageEditHoldResult,
+ AgentSessionQueuedMessageUpdateResult
+} from '../../../../shared/agent-session-wire'
+import { agentSessionSendBodyFingerprint } from '../../../../shared/structured-agent-session-send-mutation'
+import { useStructuredAgentSessionQueuedEdit } from './use-structured-agent-session-queued-edit'
+import { projectQueuedMessageCards } from '../../../../shared/structured-agent-session-queued-cards'
+import { clearNativeChatDraftCacheForTests } from './native-chat-draft-cache'
+import {
+ createMemoryNativeChatComposerDraftStorage,
+ setNativeChatComposerDraftStorageForTests
+} from './native-chat-composer-draft-storage'
+import type {
+ StructuredAgentSessionWrite,
+ StructuredAgentSessionWriteOutcome
+} from './use-structured-agent-session-mutate'
+
+export const HELD: AgentSessionQueuedMessageEditHoldResult = {
+ status: 'held',
+ fingerprint: 'f'.repeat(64),
+ leaseDurationMs: 120_000,
+ remainingMs: 120_000
+}
+const hoistedRpc = vi.hoisted(() =>
+ vi.fn(
+ async (
+ _target: unknown,
+ _method: string,
+ params: AgentSessionQueuedMessageEditHoldParams
+ ): Promise =>
+ params.action === 'release' ? { status: 'released' } : HELD
+ )
+)
+export const rpc = hoistedRpc
+vi.mock('@/runtime/structured-agent-session-client', () => ({
+ callStructuredAgentSession: hoistedRpc
+}))
+vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
+vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
+
+export const SESSION = 'folder-session'
+export const SCOPE = 'pane-draft'
+export const message: AgentSessionQueuedMessage = {
+ messageId: 'card',
+ position: 2,
+ state: 'waiting',
+ body: {
+ kind: 'message',
+ role: 'user',
+ blocks: [{ type: 'text', text: 'original' }],
+ from: { kind: 'agent', senders: [], orchestration: null }
+ }
+}
+export const BASE = agentSessionSendBodyFingerprint(SESSION, message.body)
+
+export type Outcome = StructuredAgentSessionWriteOutcome
+export const script: { answer: () => Promise } = {
+ answer: async () => ({ kind: 'dropped' })
+}
+export const writeSpy = vi.fn(async (..._args: unknown[]) => script.answer())
+// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this harness answers only queuedMessageUpdate, with that method's scripted outcome.
+export const write = writeSpy as StructuredAgentSessionWrite
+
+export type Props = {
+ messages: AgentSessionQueuedMessage[] | null
+ sessionId?: string
+ submissions?: AgentJournalSubmission[]
+ imagesCapable?: boolean
+ skillsCapable?: boolean
+ promptOpen?: boolean
+}
+export function harness(capable = true, initial: Props = { messages: [message] }) {
+ return renderHook(
+ (props: Props) => {
+ const submissions = props.submissions ?? []
+ return useStructuredAgentSessionQueuedEdit({
+ transport: {
+ target: { kind: 'environment', environmentId: 'remote-host' },
+ sessionId: props.sessionId ?? SESSION,
+ capable,
+ imagesCapable: props.imagesCapable,
+ skillsCapable: props.skillsCapable,
+ write
+ },
+ messages: props.messages,
+ cards: projectQueuedMessageCards(props.messages, submissions, {}),
+ submissions,
+ composerScopeKey: SCOPE,
+ promptOpen: props.promptOpen ?? false
+ })
+ },
+ { initialProps: initial }
+ )
+}
+export type Hook = ReturnType
+export async function begin(hook: Hook) {
+ await act(() => hook.result.current.begin('card'))
+}
+export function holdCalls(action: string) {
+ return rpc.mock.calls.filter((call) => call[2].action === action)
+}
+export function done(value: AgentSessionQueuedMessageUpdateResult): () => Promise {
+ return async () => ({ kind: 'done', value })
+}
+export function withText(text: string): AgentSessionQueuedMessage {
+ return { ...message, body: { ...message.body, blocks: [{ type: 'text', text }] } }
+}
+export function submittedAs(text: string): AgentJournalSubmission {
+ return {
+ clientMessageId: 'submission',
+ queuedMessageId: 'card',
+ payloadFingerprint: agentSessionSendBodyFingerprint(SESSION, withText(text).body),
+ fence: 3,
+ dispatchState: 'pending',
+ providerItemId: null,
+ reason: null,
+ submittedAt: 1,
+ resolvedAt: null
+ }
+}
+
+beforeEach(() => {
+ rpc
+ .mockReset()
+ .mockImplementation(async (_target, _method, params) =>
+ params.action === 'release' ? { status: 'released' } : HELD
+ )
+ writeSpy.mockClear()
+ vi.mocked(toast.error).mockClear()
+ clearNativeChatDraftCacheForTests()
+ setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage())
+ script.answer = () =>
+ Promise.resolve({
+ kind: 'done',
+ value: { status: 'updated', messageId: 'card', fingerprint: requestedFingerprint() }
+ })
+})
+afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+})
+
+export function requestedFingerprint() {
+ const fields = QueuedMessageUpdateParams.omit({ envelope: true }).parse(
+ writeSpy.mock.calls.at(-1)?.[2]
+ )
+ const edited = queuedMessageWithEdit(message.body, fields)
+ if (!edited.ok) {
+ throw new Error(edited.reason)
+ }
+ return agentSessionSendBodyFingerprint(SESSION, edited.body)
+}
diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts
index 2eb48597ea1c..c93c8a3b3f4b 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts
@@ -1,13 +1,17 @@
-import type { NativeChatComposerInput } from './native-chat-composer-input'
-import { useCallback, useEffect, useLayoutEffect, useMemo, type RefObject } from 'react'
+import type {
+ UseNativeChatComposerAttachmentsArgs,
+ NativeChatComposerAttachments
+} from './native-chat-composer-attachment-contract'
+export type { UseNativeChatComposerAttachmentsArgs } from './native-chat-composer-attachment-contract'
+import { useCallback, useEffect, useLayoutEffect, useMemo } from 'react'
import {
nativeChatComposerTargetIsRemote,
- nativeChatLocalAttachmentUnsupportedNotice,
- type NativeChatResolvedTarget
+ nativeChatLocalAttachmentUnsupportedNotice
} from './native-chat-composer-target'
-import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
-import { updateNativeChatComposerDraft } from './native-chat-composer-draft-store'
-import type { NativeChatResolvedPathOptions } from './native-chat-resolved-path-ownership'
+import {
+ captureNativeChatEditDraftDestination,
+ updateNativeChatComposerDraft
+} from './native-chat-composer-draft-store'
import { useNativeChatResolvedPathAttachments } from './use-native-chat-resolved-path-attachments'
import type {
NativeChatPendingAttachmentChips,
@@ -33,23 +37,6 @@ import {
import { isNativeChatImageAttachmentPath } from './native-chat-image-paste'
import { useNativeChatPastedTextAdmission } from './native-chat-pasted-text-state'
-export type UseNativeChatComposerAttachmentsArgs = {
- attachmentScopeKey: string
- /** False when the agent takes no image input; see `useNativeChatResolvedPathAttachments`. */
- acceptsImages?: boolean
- /** Structured chats only: each image gets a chip in the prompt where it was attached. */
- imageReferences?: boolean
- allowWithoutTarget?: boolean
- caret: number
- disabled: boolean
- isComposing: () => boolean
- resolveTarget: () => NativeChatResolvedTarget | null
- textareaRef: RefObject
- setCaret: (caret: number) => void
- setDraft: (updater: (previous: string) => string) => void
- setNotice: (notice: string | null) => void
-}
-
export function useNativeChatComposerAttachments({
attachmentScopeKey,
acceptsImages = true,
@@ -63,31 +50,15 @@ export function useNativeChatComposerAttachments({
setCaret,
setDraft,
setNotice
-}: UseNativeChatComposerAttachmentsArgs): {
- imageAttachments: NativeChatComposerImageAttachment[]
- /** A long paste in this scope is still being saved as a file; Send waits for it. */
- pastedTextSaving: boolean
- attachResolvedPaths: (
- paths: string[],
- connectionId?: string | null,
- options?: NativeChatResolvedPathOptions
- ) => void
- clearImageAttachments: () => void
- flushPendingAttachments: () => void
- removeImageAttachment: (id: string) => void
- /** `serverOwned`: an image the paste saved on the remote target's own server, not a client file. */
- beginPendingImageAttachment: (
- previewUrl?: string,
- pendingName?: string,
- serverOwned?: boolean
- ) => string | null
- resolvePendingImageAttachment: NativeChatPendingAttachmentChips['resolve']
- revealPendingImageAttachment: (id: string, previewUrl?: string) => void
- dropPendingImageAttachment: (id: string) => boolean
- pendingChips: NativeChatPendingAttachmentChips
- /** The editor while it shows this scope; null once it went or shows another. */
- scopeEditor: () => NativeChatComposerInput | null
-} {
+}: UseNativeChatComposerAttachmentsArgs): NativeChatComposerAttachments {
+ const destination = useMemo(
+ () => captureNativeChatEditDraftDestination(attachmentScopeKey),
+ [attachmentScopeKey]
+ )
+ const destinationScope = useCallback(
+ (): string => destination?.scopeKey ?? attachmentScopeKey,
+ [destination, attachmentScopeKey]
+ )
const { imageAttachments, setPreview, releasePreview, releaseAllPreviews } =
useNativeChatComposerImageChips(attachmentScopeKey)
const mountedRef = useMemo(
@@ -126,16 +97,17 @@ export function useNativeChatComposerAttachments({
// Uploads outlive conversation switches; target only this scope’s editor or stored draft.
const scopeEditor = useCallback(
- () => (mountedRef.current ? textareaRef.current : null),
- [mountedRef, textareaRef]
+ () =>
+ mountedRef.current && destinationScope() === attachmentScopeKey ? textareaRef.current : null,
+ [attachmentScopeKey, destinationScope, mountedRef, textareaRef]
)
useLayoutEffect(() => {
- if (disabled) {
- settlePendingImageReferences(attachmentScopeKey, scopeEditor(), () =>
+ if (disabled && !attachmentScopeKey.startsWith('queued-edit:')) {
+ settlePendingImageReferences(destinationScope(), scopeEditor(), () =>
clearNativeChatPendingAttachments(attachmentScopeKey)
)
}
- }, [attachmentScopeKey, disabled, scopeEditor])
+ }, [attachmentScopeKey, destinationScope, disabled, scopeEditor])
const appendImageAttachments = useCallback(
(paths: { path: string; connectionId?: string | null }[]) => {
@@ -149,14 +121,14 @@ export function useNativeChatComposerAttachments({
return
}
// One that took an unavailable image's place keeps that image's id, and so its chips.
- const row = nativeChatImageRowIds(attachmentScopeKey)
+ const row = nativeChatImageRowIds(destinationScope())
insertImageReferences(
attachmentScopeKey,
scopeEditor(),
images.flatMap(({ id }) => (row.includes(id) ? [id] : []))
)
},
- [attachmentScopeKey, imageReferences, nextAttachmentId, scopeEditor]
+ [attachmentScopeKey, destinationScope, imageReferences, nextAttachmentId, scopeEditor]
)
const { attachResolvedPaths, disabledRef, flushPendingAttachments } =
@@ -178,7 +150,7 @@ export function useNativeChatComposerAttachments({
// Show pending chips immediately so slow saves/uploads do not look like lost pastes.
const beginPendingImageAttachment = useCallback(
(previewUrl?: string, pendingName?: string, serverOwned = false): string | null => {
- if (disabledRef.current) {
+ if (destination ? !destination.active : disabledRef.current) {
return null
}
if (attachmentTargetBlocked(serverOwned)) {
@@ -189,7 +161,7 @@ export function useNativeChatComposerAttachments({
if (serverOwned) {
serverOwnedPendingIds.add(id)
}
- addNativeChatPendingAttachment(attachmentScopeKey, {
+ addNativeChatPendingAttachment(destinationScope(), {
id,
path: '',
pending: true,
@@ -202,13 +174,14 @@ export function useNativeChatComposerAttachments({
acceptsImages &&
(!pendingName || isNativeChatImageAttachmentPath(pendingName))
) {
- insertImageReferences(attachmentScopeKey, scopeEditor(), [id])
+ insertImageReferences(destinationScope(), scopeEditor(), [id])
}
return id
},
[
acceptsImages,
- attachmentScopeKey,
+ destination,
+ destinationScope,
attachmentTargetBlocked,
disabledRef,
imageReferences,
@@ -226,7 +199,7 @@ export function useNativeChatComposerAttachments({
options?: NativeChatPendingAttachmentOptions,
serverOwned = false
) =>
- settlePendingImageReferences(attachmentScopeKey, scopeEditor(), () =>
+ settlePendingImageReferences(destinationScope(), scopeEditor(), () =>
settleNativeChatPendingAttachmentReferences(
attachmentScopeKey,
references,
@@ -243,7 +216,15 @@ export function useNativeChatComposerAttachments({
: undefined
)
),
- [attachResolvedPaths, attachmentScopeKey, disabledRef, isComposing, mountedRef, scopeEditor]
+ [
+ attachResolvedPaths,
+ attachmentScopeKey,
+ destinationScope,
+ disabledRef,
+ isComposing,
+ mountedRef,
+ scopeEditor
+ ]
)
const resolvePendingImageAttachment = useCallback(
@@ -255,21 +236,21 @@ export function useNativeChatComposerAttachments({
) => {
const serverOwned = serverOwnedPendingIds.delete(id)
return mountedRef.acceptsImages
- ? settlePendingImageReferences(attachmentScopeKey, scopeEditor(), () =>
- settleNativeChatPendingAttachment(attachmentScopeKey, id, path, connectionId)
+ ? settlePendingImageReferences(destinationScope(), scopeEditor(), () =>
+ settleNativeChatPendingAttachment(destinationScope(), id, path, connectionId)
)
: attachPendingReferences([{ id, path }], options, serverOwned)
},
- [attachPendingReferences, attachmentScopeKey, mountedRef, scopeEditor, serverOwnedPendingIds]
+ [attachPendingReferences, destinationScope, mountedRef, scopeEditor, serverOwnedPendingIds]
)
const revealPendingImageAttachment = useCallback(
(id: string, previewUrl?: string) => {
- if (nativeChatPendingAttachmentSnapshot(attachmentScopeKey).some((chip) => chip.id === id)) {
+ if (nativeChatPendingAttachmentSnapshot(destinationScope()).some((chip) => chip.id === id)) {
setPreview(id, previewUrl)
}
},
- [attachmentScopeKey, setPreview]
+ [destinationScope, setPreview]
)
// Unsaved pending-chip removal must not modify the draft, even from a replaced composer.
@@ -277,9 +258,9 @@ export function useNativeChatComposerAttachments({
(id: string): boolean => {
releasePreview(id)
serverOwnedPendingIds.delete(id)
- return takePendingImageWithReferences(attachmentScopeKey, scopeEditor(), id) !== undefined
+ return takePendingImageWithReferences(destinationScope(), scopeEditor(), id) !== undefined
},
- [attachmentScopeKey, releasePreview, scopeEditor, serverOwnedPendingIds]
+ [destinationScope, releasePreview, scopeEditor, serverOwnedPendingIds]
)
const pendingChips = useMemo(
diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-command-picker.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-command-picker.ts
index cf51011fc014..a1b07fa02219 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-composer-command-picker.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-composer-command-picker.ts
@@ -1,3 +1,5 @@
+import { useCallback, useMemo } from 'react'
+import { prepareQueuedMessageEditText } from '../../../../shared/queued-message-edit-command'
import { useNativeChatComposerCatalog } from './use-native-chat-composer-catalog'
import {
useNativeChatPickerState,
@@ -19,15 +21,27 @@ export function useNativeChatComposerCommandPicker(
> &
Pick & {
structuredTransport: NativeChatStructuredComposerTransport | undefined
+ editing?: boolean
}
): { picker: NativeChatPickerState; resume: NativeChatResumeCommand } {
- const { structuredTransport, editor, setNotice, ...pickerArgs } = args
+ const { structuredTransport, editor, setNotice, editing, ...pickerArgs } = args
const { agent, terminalTabId } = pickerArgs
const { agentCommands, sessionSkills } = useNativeChatComposerCatalog(agent, structuredTransport)
+ const reported = structuredTransport?.sessionCommands
+ const hostSetsGoals = structuredTransport?.threadGoal !== undefined
+ const acceptsText = useMemo(
+ () => prepareQueuedMessageEditText(agent, reported, hostSetsGoals),
+ [agent, reported, hostSetsGoals]
+ )
+ const commandAvailable = useCallback(
+ (name: string) => !editing || acceptsText(`/${name}`),
+ [editing, acceptsText]
+ )
const picker = useNativeChatPickerState({
...pickerArgs,
...editor,
agentCommands,
+ commandAvailable,
sessionSkills,
...(structuredTransport?.refreshCommands
? { onPickerOpened: structuredTransport.refreshCommands }
diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-edit-session.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-edit-session.ts
new file mode 100644
index 000000000000..36248f96213c
--- /dev/null
+++ b/src/renderer/src/components/native-chat/use-native-chat-composer-edit-session.ts
@@ -0,0 +1,126 @@
+import { captureNativeChatEditDraftDestination } from './native-chat-composer-draft-store'
+import { useNativeChatPickerCommandDispatch } from './use-native-chat-picker-command-dispatch'
+import { useCallback, useLayoutEffect, useMemo, type RefObject } from 'react'
+import type { AgentType } from '../../../../shared/agent-status-types'
+import type { NativeChatPickerItem } from '../../../../shared/native-chat/input/native-chat-picker-items'
+import { prepareQueuedMessageEditText } from '../../../../shared/queued-message-edit-command'
+import { translate } from '@/i18n/i18n'
+import { registerDictationComposerTarget } from '../dictation/dictation-insertion-target'
+import { insertNativeChatComposerDraftText } from './native-chat-composer-draft-insertion'
+import { registerNativeChatComposerDestination } from './native-chat-composer-destination'
+import type {
+ NativeChatComposerProps,
+ NativeChatComposerHandle,
+ NativeChatStructuredComposerTransport
+} from './native-chat-composer-types'
+
+export function useNativeChatComposerEditSession({
+ editSession,
+ root,
+ handle,
+ draftScopeKey,
+ agent,
+ structuredTransport,
+ sendStructured,
+ ptyCommandRouting,
+ setNotice,
+ disabled,
+ sendHeld,
+ sendMessage,
+ completeItem
+}: {
+ editSession: NativeChatComposerProps['editSession']
+ root: RefObject
+ handle: RefObject
+ draftScopeKey: string
+ agent: AgentType
+ structuredTransport: NativeChatStructuredComposerTransport | undefined
+ sendStructured: (text: string) => void
+ ptyCommandRouting: Parameters[0]
+ setNotice: (text: string) => void
+ disabled: boolean
+ sendHeld: boolean
+ sendMessage: () => void
+ completeItem: (item: NativeChatPickerItem) => void
+}) {
+ const sessionCommands = structuredTransport?.sessionCommands
+ const hostSetsGoals = structuredTransport?.threadGoal !== undefined
+ const acceptsText = useMemo(
+ () => prepareQueuedMessageEditText(agent, sessionCommands, hostSetsGoals),
+ [agent, sessionCommands, hostSetsGoals]
+ )
+ const isEditing = editSession !== undefined
+ useLayoutEffect(() => {
+ if (root.current && handle.current) {
+ return registerNativeChatComposerDestination(root.current, handle, !isEditing, draftScopeKey)
+ }
+ return undefined
+ }, [isEditing, root, handle, draftScopeKey])
+
+ useLayoutEffect(() => {
+ const element = root.current
+ const destination = captureNativeChatEditDraftDestination(draftScopeKey)
+ if (!element || !destination) {
+ return
+ }
+ return registerDictationComposerTarget(element, (text) => {
+ if (!destination.active) {
+ return
+ }
+ insertNativeChatComposerDraftText(destination.scopeKey, text)
+ })
+ }, [draftScopeKey, root, handle])
+
+ const refuseEditCommand = useCallback(
+ (text: string): boolean => {
+ if (!editSession || acceptsText(text)) {
+ return false
+ }
+ setNotice(
+ translate(
+ 'components.native-chat.queuedMessages.commandsUnsupported',
+ "Commands can't be saved in a queued message."
+ )
+ )
+ return true
+ },
+ [editSession, setNotice, acceptsText]
+ )
+ const send = useCallback(() => {
+ if (!editSession) {
+ sendMessage()
+ return
+ }
+ if (!disabled && !sendHeld && editSession.canSave) {
+ editSession.save()
+ }
+ }, [disabled, editSession, sendHeld, sendMessage])
+ const chooseEditItem = useCallback(
+ (item: Parameters[0]) => {
+ if (item.kind === 'command' && refuseEditCommand(`/${item.name}`)) {
+ return
+ }
+ completeItem(item)
+ },
+ [completeItem, refuseEditCommand]
+ )
+
+ const dispatchPtyPickerCommand = useNativeChatPickerCommandDispatch(ptyCommandRouting)
+ const dispatchPickerCommand = useCallback(
+ (command: Parameters[0]) =>
+ structuredTransport ? sendStructured(`/${command.name}`) : dispatchPtyPickerCommand(command),
+ [structuredTransport, sendStructured, dispatchPtyPickerCommand]
+ )
+ const fieldOverrides = editSession
+ ? {
+ editSession,
+ isWorking: false,
+ goalMode: undefined,
+ sessionOptionsSurface: null,
+ contextUsage: null,
+ sessionOptionsPickerRequest: null,
+ onChoosePickerItem: chooseEditItem
+ }
+ : {}
+ return { send, chooseEditItem, fieldOverrides, dispatchPickerCommand }
+}
diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-handle.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-handle.ts
index 5d4e85b68ac3..0b0622465635 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-composer-handle.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-composer-handle.ts
@@ -49,8 +49,10 @@ export function useNativeChatComposerHandle(
useImperativeHandle(
ref,
() => ({
+ isComposing,
focus,
insertTypedText,
+ insertPastedText: insertPastedTextOrFile,
acceptsText,
appendText,
handlePasteEvent,
@@ -58,8 +60,10 @@ export function useNativeChatComposerHandle(
contains
}),
[
+ isComposing,
focus,
insertTypedText,
+ insertPastedTextOrFile,
acceptsText,
appendText,
handlePasteEvent,
diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts
index fc92d2235136..64a2c487cb35 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts
@@ -1,10 +1,10 @@
-import { useCallback, useLayoutEffect, useRef } from 'react'
+import { useNativeChatPasteDestination } from './native-chat-paste-destination'
+import { useCallback } from 'react'
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 { NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES } from './native-chat-composer-target'
-import { nativeChatPasteUnavailableNotice } from '@/lib/native-chat-paste-request'
import { isMacPlatform } from './native-chat-shortcut'
import {
clipboardEventCopiedFilePaths,
@@ -35,6 +35,7 @@ export function useNativeChatComposerPaste({
attachResolvedPaths,
attachCopiedFiles,
captureCopiedFiles,
+ captureExternalPaths,
beginPendingImageAttachment,
resolvePendingImageAttachment,
revealPendingImageAttachment,
@@ -42,14 +43,7 @@ export function useNativeChatComposerPaste({
insertTypedText,
setCaret,
setNotice
-}: UseNativeChatComposerPasteArgs): {
- handlePaste: (event: ClipboardEventLike) => void
- pasteFromClipboard: () => void
-} {
- const disabledRef = useRef(disabled)
- useLayoutEffect(() => {
- disabledRef.current = disabled
- }, [disabled])
+}: UseNativeChatComposerPasteArgs) {
const {
lifetime,
startImageChip,
@@ -65,13 +59,14 @@ export function useNativeChatComposerPaste({
revealPendingImageAttachment,
dropPendingImageAttachment
})
- const canPaste = useCallback(() => lifetime.active && !disabledRef.current, [lifetime])
- // A disabled composer still answers a paste, so it never vanishes silently.
- const showPasteUnavailable = useCallback(() => {
- if (lifetime.active) {
- setNotice(nativeChatPasteUnavailableNotice())
- }
- }, [lifetime, setNotice])
+ const { canPaste, canComplete, insertCapturedText, showPasteUnavailable } =
+ useNativeChatPasteDestination(
+ attachmentScopeKey,
+ lifetime,
+ disabled,
+ insertTypedText,
+ setNotice
+ )
/** Settle the operation started at paste time, or attach directly if chips are unavailable. */
const settleImagePaste = useCallback(
@@ -109,20 +104,20 @@ export function useNativeChatComposerPaste({
const saved = await saveNativeChatPastedClipboardImage(owner, {
besideText,
ready,
- canNotify: canPaste,
+ canNotify: canComplete,
setNotice
})
if (keepStoreUploadAfterUnmount(pendingId, saved)) {
return false
}
- if (!canPaste() || saved.status !== 'saved') {
+ if (!canComplete() || saved.status !== 'saved') {
dropImagePaste(pendingId)
return false
}
settleImagePaste(pendingId, saved.tempPath, owner)
return true
},
- [canPaste, dropImagePaste, keepStoreUploadAfterUnmount, setNotice, settleImagePaste]
+ [canComplete, dropImagePaste, keepStoreUploadAfterUnmount, setNotice, settleImagePaste]
)
const handlePaste = useCallback(
@@ -205,7 +200,6 @@ export function useNativeChatComposerPaste({
setNotice(owner.notice ?? nativeChatWorktreeNotReadyNotice())
return
}
- // Capture the caret before async saving; typing/selection can move it during the await.
const caretAtPaste = caret
// Wait for server acceptance before minting a preview; the operation is visible from the start.
const awaitServer = Boolean(text) && owner.kind === 'runtime-session'
@@ -247,11 +241,10 @@ export function useNativeChatComposerPaste({
return
}
setNotice(null)
- const insertText = (text: string): void => {
- if (text && !(canPaste() && insertTypedText(text))) {
- showPasteUnavailable()
- }
- }
+ const insertText = insertCapturedText
+ const attachFiles =
+ captureExternalPaths?.() ?? ((paths: string[]) => attachCopiedFiles(paths, { focus: false }))
+ const owner = resolveAttachmentOwner()
void (async () => {
const read = await readClipboardPasteText(NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES).catch(
(error) => {
@@ -262,14 +255,13 @@ export function useNativeChatComposerPaste({
}
)
if (read && read.copiedFilePaths.length > 0) {
- if (canPaste()) {
- attachCopiedFiles(read.copiedFilePaths, { focus: false })
+ if (canComplete()) {
+ void attachFiles(read.copiedFilePaths)
}
return
}
// Text belongs to the editor even when the attachment host is unavailable.
insertText(read?.text ?? '')
- const owner = resolveAttachmentOwner()
if (!ownerAcceptsClipboardImage(owner)) {
// Probe only when no text was typed: in a browser each clipboard read can prompt the user.
if (!read || read.text !== '') {
@@ -294,7 +286,7 @@ export function useNativeChatComposerPaste({
if (hasImage !== true) {
return
}
- if (!canPaste()) {
+ if (!canComplete()) {
return
}
// Ownership starts before saving; a thumbnail only supplies its preview.
@@ -304,8 +296,10 @@ export function useNativeChatComposerPaste({
})()
}, [
attachCopiedFiles,
+ captureExternalPaths,
+ insertCapturedText,
+ canComplete,
canPaste,
- insertTypedText,
startClipboardImageChip,
saveClipboardImage,
showPasteUnavailable,
diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.find.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.find.test.tsx
index d55099e11592..2ce9bf3d4f47 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.find.test.tsx
+++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.find.test.tsx
@@ -5,7 +5,6 @@ import { useRef } from 'react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { NativeChatFindBar } from './NativeChatFindBar'
-import { NativeChatQueuedMessageEditor } from './NativeChatQueuedMessageEditor'
import type { NativeChatComposerHandle } from './native-chat-composer-types'
import {
emptyNativeChatContextMenuActions,
@@ -66,18 +65,9 @@ function Chat(): React.JSX.Element {
- {},
- save: () => {},
- cancel: () => {}
- }}
- />
+
+
+
{contextMenu.menu}
diff --git a/src/renderer/src/components/native-chat/use-native-chat-draft-events.ts b/src/renderer/src/components/native-chat/use-native-chat-draft-events.ts
new file mode 100644
index 000000000000..9094d90bbef1
--- /dev/null
+++ b/src/renderer/src/components/native-chat/use-native-chat-draft-events.ts
@@ -0,0 +1,47 @@
+import { useCallback } from 'react'
+import type { NativeChatComposerInput } from './native-chat-composer-input'
+
+export function useNativeChatDraftEvents({
+ setDraft,
+ setCaret,
+ setActiveSuggestion,
+ handleDraftOrCaretChange,
+ flushDraftAppends,
+ flushPendingAttachments,
+ draft
+}: {
+ draft: string
+ setDraft: (text: string) => void
+ setCaret: (caret: number) => void
+ setActiveSuggestion: (index: number) => void
+ handleDraftOrCaretChange: (text: string, caret: number) => void
+ flushDraftAppends: () => void
+ flushPendingAttachments: () => void
+}) {
+ const onDraftChange = useCallback(
+ (value: string, element: NativeChatComposerInput) => {
+ const caret = element.selectionStart ?? value.length
+ setDraft(value)
+ setCaret(caret)
+ handleDraftOrCaretChange(value, caret)
+ setActiveSuggestion(0)
+ },
+ [setDraft, setCaret, handleDraftOrCaretChange, setActiveSuggestion]
+ )
+ return {
+ onDraftChange,
+ onTextareaSelect: (element: NativeChatComposerInput) => {
+ const caret = element.selectionStart ?? element.value.length
+ setCaret(caret)
+ handleDraftOrCaretChange(element.value, caret)
+ setActiveSuggestion(0)
+ },
+ onImeSettled: (element: NativeChatComposerInput) => {
+ if (element.value !== draft) {
+ onDraftChange(element.value, element)
+ }
+ flushDraftAppends()
+ flushPendingAttachments()
+ }
+ }
+}
diff --git a/src/renderer/src/components/native-chat/use-native-chat-draft.ts b/src/renderer/src/components/native-chat/use-native-chat-draft.ts
index f6c097a76a78..5938bda9a83d 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-draft.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-draft.ts
@@ -1,14 +1,22 @@
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
-import { appendReturnedDraftText } from '../../../../shared/returned-draft-text'
-import { subscribeToNativeChatComposerDraft } from './native-chat-composer-draft-store'
+import { withNativeChatComposerDraftAddition } from './native-chat-composer-draft-addition'
+import type { NativeChatDraftAppend } from './native-chat-composer-draft-memory'
+import type { NativeChatComposerDraft } from './native-chat-composer-draft-storage'
import {
- readNativeChatDraftCache,
- subscribeToNativeChatDraftAppend,
- writeNativeChatDraftCache
-} from './native-chat-draft-cache'
+ subscribeToNativeChatComposerDraft,
+ subscribeToNativeChatComposerDraftAppend,
+ readNativeChatComposerDraft,
+ updateNativeChatComposerDraft
+} from './native-chat-composer-draft-store'
+import { readNativeChatDraftCache, writeNativeChatDraftCache } from './native-chat-draft-cache'
/** Text appended mid-IME-composition: saved at once, shown once the composition settles. */
-type CompositionHold = { scopeKey: string; shown: string; appended: string }
+type CompositionHold = {
+ scopeKey: string
+ shown: string
+ base: NativeChatComposerDraft
+ additions: NativeChatDraftAppend[]
+}
/** Mutate the durable scope store’s current draft, never a composer copy, so unsent text survives toggles, remounts and restarts. */
export function useNativeChatDraft(
@@ -41,15 +49,16 @@ export function useNativeChatDraft(
useEffect(
() =>
- subscribeToNativeChatDraftAppend(scopeKey, (text, previous) => {
+ subscribeToNativeChatComposerDraftAppend(scopeKey, (addition, previous) => {
if (!isComposing()) {
return
}
const held = holdRef.current?.scopeKey === scopeKey ? holdRef.current : null
updateHold({
scopeKey,
- shown: held ? held.shown : previous,
- appended: held ? appendReturnedDraftText(held.appended, text) : text
+ shown: held ? held.shown : previous.text,
+ base: held?.base ?? previous,
+ additions: [...(held?.additions ?? []), addition]
})
}),
[isComposing, scopeKey, updateHold]
@@ -63,11 +72,25 @@ export function useNativeChatDraft(
if (held) {
updateHold({ ...held, shown: resolved })
}
- writeNativeChatDraftCache(
- scopeKey,
- held ? appendReturnedDraftText(resolved, held.appended) : resolved,
- options
- )
+ if (held) {
+ const current = readNativeChatComposerDraft(scopeKey)
+ const base =
+ current.text === resolved
+ ? current
+ : { ...held.base, text: resolved, document: undefined }
+ const merged = held.additions.reduce