diff --git a/mobile/scripts/check-tests-typecheck-incremental-cache.test.ts b/mobile/scripts/check-tests-typecheck-incremental-cache.test.ts index 3e739392033c..8b801c0032e6 100644 --- a/mobile/scripts/check-tests-typecheck-incremental-cache.test.ts +++ b/mobile/scripts/check-tests-typecheck-incremental-cache.test.ts @@ -44,7 +44,7 @@ it.each([ const source = "import type { Shape } from './model'; const shape: Shape = { value: 1 }" writeFileSync(test, source) const clean = collectTypecheckPass(root) - expect(clean).toEqual({ failing: [], programTestFiles: ['value.test.ts'] }) + expect(clean).toEqual({ failing: [], diagnostics: [], programTestFiles: ['value.test.ts'] }) if (buildInfo) { expect(existsSync(buildInfo)).toBe(true) } @@ -54,6 +54,9 @@ it.each([ writeFileSync(join(root, 'model.ts'), 'export interface Shape { value: string }') const failed = collectTypecheckPass(root) expect(failed.failing).toEqual(['value.test.ts']) + expect(failed.diagnostics).toEqual([ + expect.stringMatching(/^value\.test\.ts\(1,\d+\): error TS2322:/) + ]) expect(collectTypecheckPass(root)).toEqual(failed) writeFileSync(join(root, 'new.test.ts'), 'export const value: number = 1') @@ -64,7 +67,11 @@ it.each([ expect(findTsNocheckFiles(root, nocheck.programTestFiles)).toEqual(['value.test.ts']) unlinkSync(test) - expect(collectTypecheckPass(root)).toEqual({ failing: [], programTestFiles: ['new.test.ts'] }) + expect(collectTypecheckPass(root)).toEqual({ + failing: [], + diagnostics: [], + programTestFiles: ['new.test.ts'] + }) } finally { vi.unstubAllEnvs() removeTreeSync(root) diff --git a/mobile/scripts/check-tests-typecheck-ratchet.mjs b/mobile/scripts/check-tests-typecheck-ratchet.mjs index 93972c1a0dec..c4da37c54693 100644 --- a/mobile/scripts/check-tests-typecheck-ratchet.mjs +++ b/mobile/scripts/check-tests-typecheck-ratchet.mjs @@ -153,6 +153,7 @@ export function collectTypecheckPass(root = process.cwd()) { const output = `${result.stdout ?? ''}${result.stderr ?? ''}` return { failing: parseFailingFiles(output), + diagnostics: output.split('\n').filter((line) => ERROR_LINE.test(line)), programTestFiles: parseProgramTestFiles(output, fs.realpathSync(root)) } } @@ -277,6 +278,9 @@ export function main(root = process.cwd()) { if (added.length > 0) { printAddedFailure(added) + for (const diagnostic of pass.diagnostics) { + console.error(diagnostic) + } if (stale.length > 0) { printStaleFailure(stale) } diff --git a/mobile/scripts/check-tests-typecheck-ratchet.test.ts b/mobile/scripts/check-tests-typecheck-ratchet.test.ts index fd2c33bf3076..e8e5ea7704d3 100644 --- a/mobile/scripts/check-tests-typecheck-ratchet.test.ts +++ b/mobile/scripts/check-tests-typecheck-ratchet.test.ts @@ -1,17 +1,95 @@ +import { runProcessSync } from '@orca/process-host' import fs from 'node:fs' +import os from 'node:os' import path from 'node:path' -import { describe, expect, it } from 'vitest' -// Only the pure functions are under test; the tsc spawn is proven end to end by CI. +import { describe, expect, it, vi } from 'vitest' +import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal' import { diffBaseline, diffCensus, hasTsNocheckDirective, + main, parseBaseline, parseFailingFiles, parseProgramTestFiles, TESTS_OUTSIDE_PROGRAM } from './check-tests-typecheck-ratchet.mjs' +vi.mock('@orca/process-host', () => ({ runProcessSync: vi.fn() })) +vi.mock('./mobile-typecheck-compiler.mjs', () => ({ + resolveMobileTypecheckCommand: (project: string, cwd: string) => ({ + program: 'compiler', + args: ['--project', project], + cwd + }) +})) + +describe('ratchet failure diagnostics', () => { + it.each([ + { name: 'new failure', baseline: '', failed: true, exit: 1, printsDiagnostics: true }, + { name: 'clean pass', baseline: '', failed: false, exit: 0, printsDiagnostics: false }, + { + name: 'grandfathered failure', + baseline: 'src/value.test.ts\n', + failed: true, + exit: 0, + printsDiagnostics: false + }, + { + name: 'stale baseline', + baseline: 'src/value.test.ts\n', + failed: false, + exit: 1, + printsDiagnostics: false + } + ])('preserves $name outcome and baseline', ({ baseline, failed, exit, printsDiagnostics }) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'orca-pr31180-ratchet-')) + const diagnostic = 'src/value.test.ts(1,2): error TS2322: Type number is not assignable.' + const stderrDiagnostic = 'src/value.test.ts(2,3): error TS2345: Argument type mismatch.' + const detail = ' Type number is not assignable to type string.' + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + const logs = vi.spyOn(console, 'log').mockImplementation(() => {}) + try { + for (const file of [...TESTS_OUTSIDE_PROGRAM.keys(), 'src/value.test.ts']) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }) + fs.writeFileSync(path.join(root, file), 'export {}\n') + } + const baselineFile = path.join(root, 'tests-typecheck-baseline.txt') + fs.writeFileSync(baselineFile, baseline) + const programFile = path.join(fs.realpathSync(root), 'src/value.test.ts') + vi.mocked(runProcessSync).mockReturnValue({ + code: failed ? 2 : 0, + signal: null, + timedOut: false, + stdout: `${programFile}\n${failed ? `${diagnostic}\n${detail}\n` : ''}`, + stderr: failed ? `${stderrDiagnostic}\n` : '' + }) + + expect(main(root)).toBe(exit) + expect(runProcessSync).toHaveBeenCalledExactlyOnceWith({ + program: 'compiler', + args: ['--project', 'tsconfig.test.json', '--listFiles'], + cwd: root, + timeoutMs: null, + maxOutputBytes: 64 * 1024 * 1024 + }) + expect(errors.mock.calls.some(([message]) => message === diagnostic)).toBe(printsDiagnostics) + expect(errors.mock.calls.some(([message]) => message === stderrDiagnostic)).toBe( + printsDiagnostics + ) + expect( + errors.mock.calls.some(([message]) => message === detail || message === programFile) + ).toBe(false) + expect(fs.readFileSync(baselineFile, 'utf8')).toBe(baseline) + } finally { + vi.mocked(runProcessSync).mockReset() + errors.mockRestore() + logs.mockRestore() + removeTreeSync(root) + } + }) +}) + describe('the failing-file parser', () => { it('names each file once however many errors it carries', () => { const output = [ diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 411b3131d6e1..b005393a3410 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -1,3 +1,4 @@ +import { MobileQueuedMessageComposerContext } from './mobile-queued-message-composer-context' import { useCallback, useLayoutEffect, useMemo } from 'react' import { StyleSheet, View } from 'react-native' import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView' @@ -121,6 +122,36 @@ export function MobileNativeChatOverlay({ : null, [itemSource, fullContent.detail, load] ) + const queuedComposerContext = useMemo( + () => ({ + agent, + structured: structuredComposer ?? null, + skillDiscovery: controller.nativeChatSkills, + onNeedSkills: controller.loadNativeChatSkills, + filePaths: controller.nativeChatFilePaths, + onNeedFiles: controller.loadNativeChatFiles, + onError: (message: string) => controller.reportQueuedEditError?.(message), + onMicPress, + micActive, + dictationMode, + onMicPressIn, + onMicPressOut + }), + [ + agent, + structuredComposer, + controller.nativeChatSkills, + controller.loadNativeChatSkills, + controller.nativeChatFilePaths, + controller.loadNativeChatFiles, + controller.reportQueuedEditError, + onMicPress, + micActive, + dictationMode, + onMicPressIn, + onMicPressOut + ] + ) const noticeNames = useMemo( () => ({ hostName, ...(agent ? { agentName: tuiAgentDisplayName(agent) ?? agent } : {}) }), [hostName, agent] @@ -133,94 +164,97 @@ export function MobileNativeChatOverlay({ conversation={{ surfaceId: sendSurfaceId, sessionId }} imageClient={controller.nativeChatVisualSource?.client} > - - - - - - controller.setTabView(activeSessionTabId, 'terminal') - : undefined - } - onCancelAsk={controller.handleNativeChatCancelAsk} - onCancelPrompt={controller.handleNativeChatCancelPrompt} - onCollapseAsk={controller.collapseNativeChatAsk} - onCollapsePrompt={controller.collapseNativeChatPrompt} - collapsedPrompt={controller.nativeChatCollapsedPrompt} - question={controller.nativeChatQuestion} - onAnswerQuestion={controller.handleNativeChatQuestionAnswer} - permission={controller.nativeChatPermission} - onRespondPermission={controller.handleNativeChatRespondPermission} - composerTray={composerTray} - onOpenFile={onOpenFile} - hasMore={session.hasMore} - olderPages={session.olderPages} - onLoadEarlier={session.loadEarlier} - onSend={images.sendNativeChat} - sendSurfaceId={sendSurfaceId} - getSendCompletionGeneration={getSendCompletionGeneration} - getComposerEditGeneration={controller.getChatComposerEditGeneration} - pending={controller.chatPending} - imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId} - draftKey={controller.chatDraftKey} - onComposerTextChange={controller.setChatComposerText} - onAttachImage={ - controller.nativeChatAcceptsImages - ? () => void images.attachImage('library') - : undefined - } - attachments={images.attachments} - onRemoveAttachment={images.removeAttachment} - isAttaching={images.isAttaching} - onMicPress={onMicPress} - micActive={micActive} - dictationMode={dictationMode} - onMicPressIn={onMicPressIn} - onMicPressOut={onMicPressOut} - inputLockReason={inputLockReason} - sendErrorMessage={sendErrorMessage} - onClearSendError={onClearSendError} - filePaths={controller.nativeChatFilePaths} - onNeedFiles={controller.loadNativeChatFiles} - skillDiscovery={controller.nativeChatSkills} - onNeedSkills={controller.loadNativeChatSkills} - sessionOptions={controller.nativeChatSessionOptions} - structuredComposer={structuredComposer} - keyboardInset={keyboardInset} - /> - - - - - + + + + + + + controller.setTabView(activeSessionTabId, 'terminal') + : undefined + } + onCancelAsk={controller.handleNativeChatCancelAsk} + onCancelPrompt={controller.handleNativeChatCancelPrompt} + onCollapseAsk={controller.collapseNativeChatAsk} + onCollapsePrompt={controller.collapseNativeChatPrompt} + collapsedPrompt={controller.nativeChatCollapsedPrompt} + question={controller.nativeChatQuestion} + onAnswerQuestion={controller.handleNativeChatQuestionAnswer} + permission={controller.nativeChatPermission} + onRespondPermission={controller.handleNativeChatRespondPermission} + composerTray={composerTray} + onOpenFile={onOpenFile} + hasMore={session.hasMore} + olderPages={session.olderPages} + onLoadEarlier={session.loadEarlier} + onSend={images.sendNativeChat} + sendSurfaceId={sendSurfaceId} + getSendCompletionGeneration={getSendCompletionGeneration} + getComposerEditGeneration={controller.getChatComposerEditGeneration} + pending={controller.chatPending} + imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId} + composerScopeKey={controller.chatComposerScopeKey} + draftKey={controller.chatDraftKey} + onComposerTextChange={controller.setChatComposerText} + onAttachImage={ + controller.nativeChatAcceptsImages + ? () => void images.attachImage('library') + : undefined + } + attachments={images.attachments} + onRemoveAttachment={images.removeAttachment} + isAttaching={images.isAttaching} + onMicPress={onMicPress} + micActive={micActive} + dictationMode={dictationMode} + onMicPressIn={onMicPressIn} + onMicPressOut={onMicPressOut} + inputLockReason={inputLockReason} + sendErrorMessage={sendErrorMessage} + onClearSendError={onClearSendError} + filePaths={controller.nativeChatFilePaths} + onNeedFiles={controller.loadNativeChatFiles} + skillDiscovery={controller.nativeChatSkills} + onNeedSkills={controller.loadNativeChatSkills} + sessionOptions={controller.nativeChatSessionOptions} + structuredComposer={structuredComposer} + keyboardInset={keyboardInset} + /> + + + + + + ) } diff --git a/mobile/src/session/MobileNativeChatView.composer-lock.test.ts b/mobile/src/session/MobileNativeChatView.composer-lock.test.ts index 9d1447690d52..fa51d08350bb 100644 --- a/mobile/src/session/MobileNativeChatView.composer-lock.test.ts +++ b/mobile/src/session/MobileNativeChatView.composer-lock.test.ts @@ -183,3 +183,7 @@ describe('MobileNativeChatView: composer lock and prompt cards', () => { }) }) }) + +vi.mock('./MobileQueuedMessageComposer', () => ({ + MobileQueuedMessageComposer: 'QueuedMessageComposer' +})) diff --git a/mobile/src/session/MobileNativeChatView.failure-banner.test.ts b/mobile/src/session/MobileNativeChatView.failure-banner.test.ts index 56780fd8bbd7..1e9962fbe99c 100644 --- a/mobile/src/session/MobileNativeChatView.failure-banner.test.ts +++ b/mobile/src/session/MobileNativeChatView.failure-banner.test.ts @@ -111,3 +111,7 @@ describe('MobileNativeChatView: send and read failure banners', () => { expect(onClearSendError).toHaveBeenCalledOnce() }) }) + +vi.mock('./MobileQueuedMessageComposer', () => ({ + MobileQueuedMessageComposer: 'QueuedMessageComposer' +})) diff --git a/mobile/src/session/MobileNativeChatView.history-paging.test.ts b/mobile/src/session/MobileNativeChatView.history-paging.test.ts index d92fb9fdaf85..f111d68091b7 100644 --- a/mobile/src/session/MobileNativeChatView.history-paging.test.ts +++ b/mobile/src/session/MobileNativeChatView.history-paging.test.ts @@ -216,3 +216,7 @@ describe('MobileNativeChatView: history paging and anchoring', () => { }) }) }) + +vi.mock('./MobileQueuedMessageComposer', () => ({ + MobileQueuedMessageComposer: 'QueuedMessageComposer' +})) diff --git a/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts b/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts index f435e366c815..83c009efc9ad 100644 --- a/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts +++ b/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts @@ -250,3 +250,7 @@ describe('terminal prompt presentation with the production view, card and compos } ) }) + +vi.mock('./MobileQueuedMessageComposer', () => ({ + MobileQueuedMessageComposer: 'QueuedMessageComposer' +})) diff --git a/mobile/src/session/MobileNativeChatView.rows.test.ts b/mobile/src/session/MobileNativeChatView.rows.test.ts index a263b8e6cbd8..7d813c307dc3 100644 --- a/mobile/src/session/MobileNativeChatView.rows.test.ts +++ b/mobile/src/session/MobileNativeChatView.rows.test.ts @@ -74,3 +74,7 @@ describe('MobileNativeChatView: rows and the streaming bubble', () => { expect(renderedRow('pending-1').props).not.toHaveProperty('queued') }) }) + +vi.mock('./MobileQueuedMessageComposer', () => ({ + MobileQueuedMessageComposer: 'QueuedMessageComposer' +})) diff --git a/mobile/src/session/MobileNativeChatView.stop.test.ts b/mobile/src/session/MobileNativeChatView.stop.test.ts index b76341178ea5..ac17509a2d00 100644 --- a/mobile/src/session/MobileNativeChatView.stop.test.ts +++ b/mobile/src/session/MobileNativeChatView.stop.test.ts @@ -105,3 +105,7 @@ describe('MobileNativeChatView: Stop control', () => { expect(stops()).toHaveLength(1) }) }) + +vi.mock('./MobileQueuedMessageComposer', () => ({ + MobileQueuedMessageComposer: 'QueuedMessageComposer' +})) diff --git a/mobile/src/session/MobileNativeChatView.tail-follow.test.ts b/mobile/src/session/MobileNativeChatView.tail-follow.test.ts index 95666c6946be..fce4ba34c760 100644 --- a/mobile/src/session/MobileNativeChatView.tail-follow.test.ts +++ b/mobile/src/session/MobileNativeChatView.tail-follow.test.ts @@ -382,3 +382,7 @@ describe('MobileNativeChatView: tail-follow', () => { } }) }) + +vi.mock('./MobileQueuedMessageComposer', () => ({ + MobileQueuedMessageComposer: 'QueuedMessageComposer' +})) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 6e7dfb15819d..7fe3da017d20 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -100,6 +100,7 @@ type Props = ComposerTrayProps & { pending: MobileNativeChatPendingItem[] // Retain local preview URIs after authoritative image messages replace optimistic rows. imagePreviewsByMessageId?: Record + composerScopeKey?: string | null draftKey: string | null onComposerTextChange: (text: string) => void onAttachImage?: () => void @@ -171,6 +172,7 @@ export function MobileNativeChatView({ pending, imagePreviewsByMessageId, draftKey, + composerScopeKey, onComposerTextChange, onAttachImage, attachments, @@ -388,6 +390,7 @@ export function MobileNativeChatView({ {} {trayContent} { }) }) }) + +vi.mock('./MobileQueuedMessageComposer', () => ({ + MobileQueuedMessageComposer: 'QueuedMessageComposer' +})) diff --git a/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts b/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts index 920c949ef6a6..118466aca556 100644 --- a/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts +++ b/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts @@ -178,3 +178,7 @@ it('keeps a steer on its way into an ordinary running turn in the list, above th expect(drawn).toEqual({ listed: ['ask', 'steer'], footer: ['status'] }) }) + +vi.mock('./MobileQueuedMessageComposer', () => ({ + MobileQueuedMessageComposer: 'QueuedMessageComposer' +})) diff --git a/mobile/src/session/MobileQueuedMessageCardRow.tsx b/mobile/src/session/MobileQueuedMessageCardRow.tsx index 794a7456cda9..6fd00814fa10 100644 --- a/mobile/src/session/MobileQueuedMessageCardRow.tsx +++ b/mobile/src/session/MobileQueuedMessageCardRow.tsx @@ -5,6 +5,7 @@ import { AlertCircle, CornerDownRight, ListEnd, + Image as ImageIcon, MoreHorizontal, Send, Trash2 @@ -14,7 +15,7 @@ import { queuedMessageCardSteers } from '../../../src/shared/structured-agent-se import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' import type { MobileQueuedMessageCard } from './mobile-structured-queued-message-cards' import type { MobileQueuedMessageInlineEditor } from './use-mobile-structured-queued-edit' -import { MobileNativeChatQueuedMessageEditor } from './native-chat/input/MobileNativeChatQueuedMessageEditor' +import { MobileQueuedMessageComposer } from './MobileQueuedMessageComposer' import { MobileQueuedCardText } from './MobileQueuedCardText' import { queuedMessageStyles as styles } from './native-chat/input/mobile-native-chat-queued-message-styles' @@ -45,7 +46,7 @@ export function MobileQueuedMessageCardRow({ style={styles.divided} onLayout={(event) => onEditorLayout(event.nativeEvent.layout)} > - + ) } @@ -93,7 +94,16 @@ function QueuedMessageCardContents({ ) : null} - {} + {card.images ? ( + + + {card.images.length} + + ) : null} {card.waitsForAgent ? null : ( - {} {card.command ? null : ( ({ Text: 'Text', View: 'View' })) +vi.mock('./native-chat/composer/MobileNativeChatComposer', () => ({ + MobileNativeChatComposer: 'Composer' +})) +vi.mock('./native-chat/composer/use-mobile-native-chat-file-attachments', () => ({ + useMobileNativeChatFileAttachments: () => ({ + files: [], + attachFiles: vi.fn(), + removeFiles: vi.fn() + }) +})) +vi.mock('./native-chat/composer/use-mobile-native-chat-image-upload', () => ({ + useMobileNativeChatImageUpload: () => ({ attachImage: vi.fn(async () => {}), isAttaching: false }) +})) + +describe('queued phone composer action boundary', () => { + let renderer: ReactTestRenderer | undefined + afterEach(() => { + act(() => renderer?.unmount()) + resetMobileNativeChatDraftInputsForTests() + }) + + it('saves literal text while refusing picked executable controls and retaining skill picks', async () => { + const save = vi.fn(async () => true) + const onError = vi.fn() + const client = new FakeSession('connected') + await act(async () => { + renderer = create( + createElement( + MobileQueuedMessageComposerContext.Provider, + { + value: { + agent: 'codex', + structured: null, + filePaths: [], + onNeedFiles: undefined, + onError, + onMicPress: undefined, + micActive: false, + dictationMode: undefined, + onMicPressIn: vi.fn(), + onMicPressOut: vi.fn() + } + }, + createElement(MobileQueuedMessageComposer, { + attribution: null, + editor: { + messageId: 'card', + scopeKey: 'edit', + sessionId: 'session', + client, + imagesCapable: true, + text: 'hello', + saving: false, + canSave: true, + change: vi.fn(), + save, + removeImage: vi.fn(), + cancel: vi.fn() + } + }) + ) + ) + }) + const composer = findByHostType(renderer!.root, 'Composer') + await act(async () => { + expect(await composer.props.onSend('/compact')).toBe(true) + expect(await composer.props.onSend('/goal research')).toBe(true) + expect(composer.props.editSession.refuseCommand('/resume')).toBe(true) + }) + expect(save).toHaveBeenCalledTimes(2) + save.mockClear() + expect(client.sendRequest).not.toHaveBeenCalled() + expect(onError).toHaveBeenCalledOnce() + act(() => composer.props.onPickSkill({ name: 'review', path: '/remote/SKILL.md' })) + expect(readMobileNativeChatDraftInputs('edit').skills).toEqual([ + { name: 'review', path: '/remote/SKILL.md' } + ]) + await act(async () => expect(await composer.props.onSend('hello')).toBe(true)) + expect(save).toHaveBeenCalledOnce() + expect(client.sendRequest).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/session/MobileQueuedMessageComposer.tsx b/mobile/src/session/MobileQueuedMessageComposer.tsx new file mode 100644 index 000000000000..15323dd13f46 --- /dev/null +++ b/mobile/src/session/MobileQueuedMessageComposer.tsx @@ -0,0 +1,123 @@ +import { useContext, useEffect, useMemo, useRef } from 'react' +import { MobileQueuedMessageComposerContext } from './mobile-queued-message-composer-context' +import { Text, type TextInput, View } from 'react-native' +import { MobileNativeChatComposer } from './native-chat/composer/MobileNativeChatComposer' +import type { MobileQueuedMessageInlineEditor } from './use-mobile-structured-queued-edit' +import { useMobileNativeChatFileAttachments } from './native-chat/composer/use-mobile-native-chat-file-attachments' +import { useMobileNativeChatImageUpload } from './native-chat/composer/use-mobile-native-chat-image-upload' +import { + updateMobileNativeChatDraftInputs, + useMobileNativeChatDraftInputs +} from './native-chat/composer/mobile-native-chat-draft-inputs' +import { prepareQueuedMessageEditText } from '../../../src/shared/queued-message-edit-command' +import { colors } from '../theme/mobile-theme' +import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' +import { createUuidV4 } from '../../../src/shared/uuid-v4' + +export function MobileQueuedMessageComposer({ + editor, + attribution +}: { + editor: MobileQueuedMessageInlineEditor + attribution: string | null +}): React.JSX.Element { + useMobileColorScheme() + const context = useContext(MobileQueuedMessageComposerContext) + const inputRef = useRef(null) + const emptyHandle = useRef(null) + const inputs = useMobileNativeChatDraftInputs(editor.scopeKey) + const files = useMobileNativeChatFileAttachments({ + client: editor.client, + sessionId: editor.sessionId, + scopeKey: editor.scopeKey, + enabled: !editor.saving, + showToast: (message) => context?.onError(message) + }) + const images = useMobileNativeChatImageUpload({ + client: editor.client, + activeHandleRef: emptyHandle, + getActiveWorktreeConnectionId: async () => null, + connState: 'connected', + scopeKey: editor.scopeKey, + structuredNativeChat: true, + showToast: (message) => context?.onError(message), + onImagesUploaded: (scope, images) => + updateMobileNativeChatDraftInputs(scope, (current) => ({ + ...current, + images: [...current.images, ...images.map((image) => ({ ...image, id: createUuidV4() }))] + })) + }) + useEffect(() => { + inputRef.current?.focus() + }, []) + const agent = context?.agent ?? 'codex' + const reported = context?.structured?.catalog.reported + const acceptsText = useMemo( + () => prepareQueuedMessageEditText(agent, reported), + [agent, reported] + ) + const refuseCommand = (text: string): boolean => { + if (acceptsText(text)) { + return false + } + context?.onError("Commands can't be saved in a queued message.") + return true + } + return ( + + + Editing message{attribution ? ` · ${attribution}` : ''} + + editor.save()} + editSession={{ cancel: editor.cancel, locked: editor.saving, refuseCommand }} + sendSurfaceId={editor.scopeKey} + getSendCompletionGeneration={() => 0} + getComposerEditGeneration={() => 0} + disabled={editor.saving} + sendDisabled={!editor.canSave} + structured={ + context?.structured + ? { + ...context.structured, + files: files.files, + onAttachFiles: () => void files.attachFiles(), + onRemoveFile: (id) => files.removeFiles([id]) + } + : null + } + skillDiscovery={context?.skillDiscovery} + onNeedSkills={context?.onNeedSkills} + filePaths={context?.filePaths} + onNeedFiles={context?.onNeedFiles} + onPickSkill={(skill) => + updateMobileNativeChatDraftInputs(editor.scopeKey, (current) => ({ + ...current, + skills: [...current.skills.filter(({ name }) => name !== skill.name), skill] + })) + } + attachments={[...inputs.images]} + onRemoveAttachment={editor.imagesCapable ? editor.removeImage : undefined} + onAttachImage={ + editor.imagesCapable + ? () => { + void images.attachImage('library') + } + : undefined + } + isAttaching={images.isAttaching} + onMicPress={context?.onMicPress} + micActive={context?.micActive} + dictationMode={context?.dictationMode} + onMicPressIn={context?.onMicPressIn} + onMicPressOut={context?.onMicPressOut} + /> + + ) +} diff --git a/mobile/src/session/MobileQueuedMessageScrollList.tsx b/mobile/src/session/MobileQueuedMessageScrollList.tsx index 5af2076f9e60..05f23acaaa5b 100644 --- a/mobile/src/session/MobileQueuedMessageScrollList.tsx +++ b/mobile/src/session/MobileQueuedMessageScrollList.tsx @@ -23,15 +23,17 @@ export type MobileQueueDragViewport = { type Metrics = { offset: number; viewport: number; content: number } -/** The queued cards under the fixed header, capped so a long queue never takes over the screen. */ +/** Compact cards are capped; the full editor yields to the available chat viewport. */ export function MobileQueuedMessageScrollList({ messageIds, newestIsPersons, + inlineEditorActive = false, children }: { messageIds: readonly string[] /** The newest card has no sender: the person just queued it, so the list shows it. */ newestIsPersons: boolean + inlineEditorActive?: boolean /** Rows, given `reveal` to keep an edited row in view. */ children: (reveal: (row: LayoutRectangle) => void, drag: MobileQueueDragViewport) => ReactNode }): React.JSX.Element { @@ -75,17 +77,17 @@ export function MobileQueuedMessageScrollList({ // Never past the content's end, which may not have caught up with a row that just grew. const top = Math.max(0, row.y - spacing.lg) const bottom = Math.min(Math.max(content, rowBottom), rowBottom + spacing.lg) - if (top < offset) { + if (row.height > viewport || top < offset) { scrollRef.current?.scrollTo({ y: top, animated: false }) } else if (bottom > offset + viewport) { scrollRef.current?.scrollTo({ y: bottom - viewport, animated: false }) } } return ( - + nativeChatAgent: string | null + chatComposerScopeKey?: string | null + reportQueuedEditError?: (message: string) => void chatDraftKey: string | null setChatComposerText: Dispatch> getChatComposerEditGeneration: () => number diff --git a/mobile/src/session/mobile-prompt-controller.test.ts b/mobile/src/session/mobile-prompt-controller.test.ts index ee852879f5a1..d6be2d326337 100644 --- a/mobile/src/session/mobile-prompt-controller.test.ts +++ b/mobile/src/session/mobile-prompt-controller.test.ts @@ -282,3 +282,7 @@ describe('prompt cards through the production controller, send contract and view } ) }) + +vi.mock('./MobileQueuedMessageComposer', () => ({ + MobileQueuedMessageComposer: 'QueuedMessageComposer' +})) diff --git a/mobile/src/session/mobile-queued-edit-composer.test.tsx b/mobile/src/session/mobile-queued-edit-composer.test.tsx new file mode 100644 index 000000000000..5413974164b6 --- /dev/null +++ b/mobile/src/session/mobile-queued-edit-composer.test.tsx @@ -0,0 +1,628 @@ +import type { NativeChatSkillDiscoverySnapshot } from '../../../src/shared/native-chat/input/native-chat-picker-items' +import { QueuedMessageUpdateParams } from '../../../src/shared/rpc-contract/structured-agent-session-queued-edit-params' +import { createElement, type ReactNode } from 'react' +import { setMobileNativeChatDraftText } from './native-chat/composer/mobile-native-chat-draft-store' +import { + mobileNativeChatDraftDestination, + captureMobileNativeChatTextDestination, + readMobileNativeChatDraftInputs +} from './native-chat/composer/mobile-native-chat-draft-inputs' +import { act } from 'react-test-renderer' +import { beforeAll, describe, expect, it, vi } from 'vitest' +import type { + AcpCommandOptionsFixtureModule, + MobileQueuedEditHostFixtureModule +} from '../../../src/shared/mobile-queued-edit-host.test-fixture-contract' +import { mobileQueuedEditHarness, readOrHoldReply } from './mobile-queued-edit-hook.test-fixture' +import { TextInput } from 'react-native' +import { ActionSheetModal } from '../components/ActionSheetModal' +import { MobileQueuedMessageComposerContext } from './mobile-queued-message-composer-context' +import { MobileNativeChatQueuedMessages } from './native-chat/input/MobileNativeChatQueuedMessages' +import { MobileNativeChatComposer } from './native-chat/composer/MobileNativeChatComposer' +import { MobileQueuedMessageComposer } from './MobileQueuedMessageComposer' +import type { QueuedEditHostFixtureModule } from '../../../src/shared/queued-edit-host.test-fixture' +import { queuedMessageWithEdit } from '../../../src/shared/queued-message-edit' +import { + mutationOk, + queuedDraft, + SESSION_ID +} from './use-mobile-structured-agent-session-queued.test-fixture' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' + +let queuedEditHost: QueuedEditHostFixtureModule['queuedEditHost'] +beforeAll(async () => { + const fixtures = await vi.importActual( + '../../../src/main/native-chat/agent-session-journal/queued-edit-host.test-fixture' + ) + queuedEditHost = fixtures.queuedEditHost +}) + +const picker = vi.hoisted(() => ({ pickFiles: vi.fn(), pickImages: vi.fn() })) +vi.mock('../platform/media-picker', () => ({ useMediaPicker: () => picker })) +vi.mock('../components/DragReorderList', () => ({ DragReorderList: 'DragReorderList' })) +vi.mock('react-native-reanimated', async () => { + const { useRef } = await import('react') + return { + default: { ScrollView: 'ScrollView' }, + useAnimatedRef: () => useRef(null), + useSharedValue: (value: unknown) => useRef({ value }).current + } +}) +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) +vi.mock('../components/mounted-bottom-drawer', () => ({ MountedBottomDrawer: 'BottomDrawer' })) +vi.mock('../components/bottom-drawer-modal-host-scope', () => ({ + useBottomDrawerModalHost: () => null +})) + +const hostFixtures = await vi.importActual( + '../../../src/main/native-chat/agent-session-wire/mobile-queued-edit-host.test-fixture' +) +const { AcpStructuredOptions } = await vi.importActual( + '../../../src/main/acp/acp-structured-options' +) + +describe('real phone inline composer from More through picker pruning and host body', () => { + const { state, Harness, mount, sendRequest, error, resolved } = mobileQueuedEditHarness() + const profiles = [ + ['claude', 'model', '/model'], + ['codex', 'review.v2', '$review.v2'], + ['pi', 'skill:review', '/skill:review'], + ['openclaude', 'review', '/review'], + ['opencode', 'review', '/review'], + ['opencode2', 'review', '/review'], + ['grok', 'review', '/review'] + ] as const + async function rows( + message: Parameters[0], + agent = 'codex', + sessionId = SESSION_ID, + catalog: { + reported: { name: string; kind: 'skill' | 'command'; path?: string }[] + discovery?: NativeChatSkillDiscoverySnapshot + } = { + reported: [] + }, + extra?: (hook: NonNullable) => ReactNode + ) { + await mount(message, true, agent, sessionId) + act(() => state.hook!.queued.editor!.cancel()) + resolved.mockClear() + const renderContent: NonNullable[0]['renderContent']> = (hook) => + createElement( + MobileQueuedMessageComposerContext.Provider, + { + value: { + agent, + structured: { + catalog: { reported: catalog.reported, conversationCommands: [] }, + onSlashMenuOpen: () => {}, + files: [], + onRemoveFile: () => {} + }, + skillDiscovery: catalog.discovery ?? { + status: 'ready', + skills: catalog.reported + .filter((entry) => entry.kind === 'skill' && entry.path) + .map((entry) => ({ + id: entry.name, + name: entry.name, + description: null, + providers: ['agent-skills'], + sourceKind: 'repo', + sourceLabel: 'Repo', + rootPath: '/skills', + directoryPath: `/skills/${entry.name}`, + skillFilePath: entry.path ?? '', + installed: true, + updatedAt: null + })) + }, + filePaths: [], + onNeedFiles: undefined, + onError: error, + onMicPress: undefined, + micActive: false, + dictationMode: undefined, + onMicPressIn: () => {}, + onMicPressOut: () => {} + } + }, + createElement(MobileNativeChatQueuedMessages, { + cards: hook.queued.cards, + onEdit: hook.queued.edit, + editor: hook.queued.editor + }), + extra?.(hook) + ) + const rerender = async () => + act(async () => + state.renderer!.update( + createElement(Harness, { + sessionId, + agent, + renderContent, + skillsSupport: true, + imagesSupport: true, + composerScopeKey: 'original-bottom' + }) + ) + ) + await rerender() + const root = () => state.renderer!.root + const button = (label: string) => + root().findAll( + (node) => + node.props.accessibilityLabel === label && typeof node.props.onPress === 'function' + )[0]! + const field = () => + root().find((node) => node.type === TextInput && node.props.multiline === true) + const open = async () => { + await act(async () => button('More actions').props.onPress()) + const sheet = root().find( + (node) => + node.type === ActionSheetModal && node.props.actions?.[0]?.label === 'Edit message' + ) + await act(async () => sheet.props.actions[0].onPress()) + expect(root().findAllByType(MobileQueuedMessageComposer)).toHaveLength(1) + expect(root().findAllByType(MobileNativeChatComposer)).toHaveLength(extra ? 2 : 1) + } + await open() + return { root, button, field, rerender, open } + } + it.each(profiles)( + '%s preserves its actual offered %s shape through the wrapper and accepted body', + async (agent, name, token) => { + const message = queuedDraft({ + messageId: 'picked', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'seed' }] } + }) + const host = queuedEditHost(SESSION_ID, message) + let path = '/A/SKILL.md' + let unknown = agent === 'claude' + let desired = message.body + const scanned = agent === 'opencode' || agent === 'opencode2' + const options = new AcpStructuredOptions() + options.adoptCommands([{ name, description: 'Review the prompt' }]) + const catalog: NonNullable[3]> = { + reported: scanned ? (options.readCommands() ?? []) : [{ name, kind: 'skill', path }], + ...(scanned + ? { + discovery: { + status: 'ready', + skills: [ + { + id: 'repo:review', + name, + description: null, + providers: ['agent-skills'], + sourceKind: 'repo', + sourceLabel: 'Repo', + rootPath: '/skills', + directoryPath: '/skills/review', + skillFilePath: path, + installed: true, + updatedAt: null + } + ] + } + } + : {}) + } + const { button, field, rerender } = await rows(message, agent, SESSION_ID, catalog) + sendRequest.mockImplementation(async (method, params) => { + if (method !== 'agentSession.queuedMessageUpdate') { + return readOrHoldReply(method, params) + } + const fields = QueuedMessageUpdateParams.parse(params) + const edited = queuedMessageWithEdit(host.row!.body, fields) + if (!edited.ok) { + throw new Error(edited.reason) + } + desired = edited.body + if (unknown) { + throw markRpcDeliveryUnknown(new Error('lost')) + } + return mutationOk(host.update(fields)) + }) + const pick = async () => { + act(() => field().props.onChangeText('/')) + act(() => + field().props.onSelectionChange({ nativeEvent: { selection: { start: 1, end: 1 } } }) + ) + await act(async () => button(token).props.onPress()) + expect(state.hook!.queued.editor?.text).toBe(`${token} `) + expect(readMobileNativeChatDraftInputs(state.hook!.queued.editor!.scopeKey).skills).toEqual( + scanned ? [] : [{ name, path }] + ) + } + await pick() + await act(async () => button('Save edited message').props.onPress()) + if (unknown) { + const first = desired + path = '/B/SKILL.md' + catalog.reported = [{ name, kind: 'skill', path }] + await rerender() + // Reopen menu after deleting: deletion retires A before picking B. + act(() => field().props.onChangeText('')) + await pick() + act(() => field().props.onChangeText(`${token} suffix`)) + const { batchEvent } = + await import('./use-mobile-structured-agent-session-queued.test-fixture') + act(() => state.listener?.(batchEvent([{ ...message, body: first }]))) + expect(state.hook!.queued.editor?.text).toBe(`${token} suffix`) + expect(resolved).not.toHaveBeenCalled() + expect(state.bottomText).toBe('') + host.update({ + messageId: 'picked', + expectedBodyFingerprint: host.row!.fingerprint, + text: token, + images: [], + skills: [{ name, path: '/A/SKILL.md' }] + }) + unknown = false + await act(async () => button('Save edited message').props.onPress()) + } + expect(error.mock.calls).toEqual([]) + expect( + sendRequest.mock.calls.filter(([method]) => method === 'agentSession.queuedMessageUpdate') + ).not.toHaveLength(0) + expect(state.hook!.queued.editor).toBeUndefined() + expect(host.row!.body.skills).toEqual(scanned ? undefined : [{ name, path }]) + expect( + sendRequest.mock.calls.filter(([method]) => method === 'agentSession.queuedMessageUpdate') + ).toHaveLength(agent === 'claude' ? 2 : 1) + expect( + sendRequest.mock.calls.some(([method]) => + /(?:\.send$|conversationCommand|goal|resume|pty)/i.test(method) + ) + ).toBe(false) + host.leases.dispose() + } + ) + it('keeps both saved files on clean Save and projects the phone picked list after an argument edit', async () => { + const skills = [ + { name: 'review', path: '/A/SKILL.md' }, + { name: 'review', path: '/B/SKILL.md' } + ] + const message = queuedDraft({ + messageId: 'two-files', + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '$review old' }], + skills + } + }) + const host = queuedEditHost(SESSION_ID, message) + try { + const { button, field, open } = await rows(message) + sendRequest.mockImplementation(async (method, params) => + method === 'agentSession.queuedMessageUpdate' + ? mutationOk(host.update(QueuedMessageUpdateParams.parse(params))) + : readOrHoldReply(method, params) + ) + await act(async () => button('Save edited message').props.onPress()) + expect(state.hook!.queued.editor).toBeUndefined() + expect( + sendRequest.mock.calls.filter(([method]) => method === 'agentSession.queuedMessageUpdate') + ).toHaveLength(0) + expect(host.row!.body.skills).toEqual(skills) + await open() + act(() => field().props.onChangeText('$review changed')) + await act(async () => button('Save edited message').props.onPress()) + expect(state.hook!.queued.editor).toBeUndefined() + expect(host.row!.body.skills).toEqual(skills) + expect(host.row!.body.blocks).toEqual([{ type: 'text', text: '$review changed' }]) + expect( + sendRequest.mock.calls.filter(([method]) => method === 'agentSession.queuedMessageUpdate') + ).toHaveLength(1) + } finally { + host.leases.dispose() + } + }) + + it.each(['question', 'approval'] as const)( + 'automatic/mail More→Edit→Save persists user authorship, position and options across journal reopen under %s', + async (kind) => { + const { ok, snapshotEvent } = + await import('./use-mobile-structured-agent-session-queued.test-fixture') + const clock = vi.spyOn(Date, 'now').mockReturnValue(hostFixtures.now) + const rig = await hostFixtures.createMobileQueuedEditHostFixture() + try { + await rig.start() + const from = { kind: 'agent' as const, senders: [], orchestration: null } + const before = await rig.insert({ + messageId: `mail-${kind}`, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'mail intent' }], + sentAs: 'goal', + from + }, + options: { model: 'saved-model', effort: 'high' } + }) + const beforeWire = await rig.published(before.messageId) + const { button, field, open } = await rows(beforeWire, 'codex', rig.sessionId) + act(() => state.hook!.queued.editor!.cancel()) + const pending = { + options: [{ id: 'yes', label: 'Yes' }], + resolution: { + state: 'pending' as const, + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } + const prompt = snapshotEvent({ + queuedMessages: [beforeWire], + items: [ + { + itemId: 'prompt', + revision: 1, + sequence: 1, + observedAt: 1, + body: + kind === 'question' + ? { kind, question: 'Pick', ...pending } + : { kind, title: 'Allow?', detail: null, ...pending } + } + ] + }) + act(() => + state.listener?.({ + ...prompt, + sessionId: rig.sessionId, + page: { ...prompt.page, sessionId: rig.sessionId } + }) + ) + await open() + const hostResults: unknown[] = [] + sendRequest.mockImplementation(async (method, params) => { + if (method === 'agentSession.queuedMessageEditHold') { + return ok(await rig.request(method, params)) + } + if (method !== 'agentSession.queuedMessageUpdate') { + return readOrHoldReply(method, params) + } + const result = await rig.request(method, params) + hostResults.push(result) + return ok(result) + }) + // Equal Save proves no durable sender mutation or update RPC. + await act(async () => button('Save edited message').props.onPress()) + expect(rig.read(before.messageId)?.body.from).toEqual(from) + expect( + sendRequest.mock.calls.some(([method]) => method === 'agentSession.queuedMessageUpdate') + ).toBe(false) + await open() + act(() => field().props.onChangeText('user replacement')) + await act(async () => button('Save edited message').props.onPress()) + expect(hostResults, JSON.stringify(hostResults)).toMatchObject([ + { ok: true, value: { status: 'updated' } } + ]) + expect(error.mock.calls).toEqual([]) + expect(state.hook!.queued.editor).toBeUndefined() + const accepted = rig.read(before.messageId)! + expect(accepted).toMatchObject({ + position: before.position, + options: before.options, + body: { sentAs: 'goal', blocks: [{ type: 'text', text: 'user replacement' }] } + }) + expect(accepted.body).not.toHaveProperty('from') + await rig.restart() + const reopened = rig.read(before.messageId)! + expect(reopened).toEqual(accepted) + const publication = snapshotEvent({ + queuedMessages: [await rig.published(reopened.messageId)] + }) + act(() => + state.listener?.({ + ...publication, + sessionId: rig.sessionId, + page: { ...publication.page, sessionId: rig.sessionId } + }) + ) + await open() + expect(state.hook!.queued.editor?.text).toBe('user replacement') + expect(state.hook!.queued.cards[0]?.attribution).toBeNull() + } finally { + await rig.dispose() + clock.mockRestore() + } + }, + 15000 + ) + + it.each(['updated', 'unknown', 'refused', 'unknown-phase'] as const)( + 'real phone Cancel revokes pending/unknown Save before late %s and cannot mutate a new edit', + async (mode) => { + const { batchEvent, fieldsOf } = + await import('./use-mobile-structured-agent-session-queued.test-fixture') + const message = queuedDraft({ messageId: 'pending-cancel' }) + const other = queuedDraft({ messageId: 'new-edit', position: 2 }) + const host = queuedEditHost(SESSION_ID, message) + const { button, field } = await rows(message) + act(() => setMobileNativeChatDraftText('original-bottom', 'occupied')) + act(() => field().props.onChangeText('B')) + act(() => field().props.onFocus()) + const dictate = captureMobileNativeChatTextDestination()! + expect(dictate).toBeTypeOf('function') + const oldScope = state.hook!.queued.editor!.scopeKey + const destination = mobileNativeChatDraftDestination(oldScope) + let release = () => {} + const pending = new Promise((resolve) => { + release = resolve + }) + let oldEditId = '' + sendRequest.mockImplementation(async (method, params) => { + if ( + method === 'agentSession.queuedMessageEditHold' && + fieldsOf(params).action === 'release' + ) { + oldEditId = String(fieldsOf(params).editId) + } + if (method !== 'agentSession.queuedMessageUpdate') { + return readOrHoldReply(method, params) + } + const decision = + mode === 'refused' + ? { status: 'not-editable' as const, messageId: message.messageId } + : host.update(QueuedMessageUpdateParams.parse(params)) + expect(decision.status).toBe(mode === 'refused' ? 'not-editable' : 'updated') + await pending + if (mode === 'unknown' || mode === 'unknown-phase') { + throw markRpcDeliveryUnknown(new Error('lost')) + } + return mutationOk(decision) + }) + let save!: Promise + act(() => { + save = button('Save edited message').props.onPress() + }) + expect(state.hook!.queued.editor?.saving).toBe(true) + expect(field().props.editable).toBe(false) + expect(button('Save edited message').props.disabled).toBe(true) + expect(button('Cancel editing').props.disabled ?? false).toBe(false) + if (mode === 'unknown-phase') { + await act(async () => { + release() + await save + }) + expect(state.hook!.queued.editor?.saving).toBe(false) + expect(field().props.editable).toBe(true) + } + await act(async () => button('Cancel editing').props.onPress()) + expect(state.hook!.queued.editor).toBeUndefined() + expect(destination.active).toBe(false) + expect(oldEditId).not.toBe('') + expect(state.bottomText).toBe('occupied') + act(() => state.listener?.(batchEvent([message, other]))) + await act(async () => { + await state.hook!.queued.edit!(other.messageId) + }) + act(() => field().props.onChangeText('fresh input')) + const newScope = state.hook!.queued.editor!.scopeKey + act(() => expect(dictate('late speech')).toBe(false)) + await act(async () => { + release() + await save + }) + act(() => state.listener?.(batchEvent([{ ...message, body: host.row!.body }, other]))) + expect(state.hook!.queued.editor).toMatchObject({ + messageId: other.messageId, + text: 'fresh input', + scopeKey: newScope, + saving: false + }) + expect(state.bottomText).toBe('occupied') + expect(readMobileNativeChatDraftInputs(oldScope)).toMatchObject({ + files: [], + images: [], + skills: [] + }) + expect(error).not.toHaveBeenCalled() + expect(resolved).not.toHaveBeenCalled() + expect( + sendRequest.mock.calls.filter( + ([method, params]) => + method === 'agentSession.queuedMessageEditHold' && + fieldsOf(params).action === 'release' && + fieldsOf(params).editId === oldEditId + ) + ).toHaveLength(1) + host.leases.dispose() + } + ) + + it.each(['files', 'images'] as const)( + 'real %s upload Cancel revokes its captured destination while normal pending admission blocks Save', + async (kind) => { + const { ok } = await import('./use-mobile-structured-agent-session-queued.test-fixture') + const message = queuedDraft({ messageId: `cancel-${kind}` }) + const { root, button } = await rows(message) + act(() => setMobileNativeChatDraftText('original-bottom', 'occupied')) + const scope = state.hook!.queued.editor!.scopeKey + const destination = mobileNativeChatDraftDestination(scope) + let release = () => {} + const pending = new Promise((resolve) => { + release = resolve + }) + let started = () => {} + const start = new Promise((resolve) => { + started = resolve + }) + picker.pickFiles.mockImplementation(async function* () { + yield { name: 'late.txt', mime: 'text/plain', byteLength: 1, base64: 'YQ==' } + yield { name: 'never.txt', mime: 'text/plain', byteLength: 1, base64: 'Yg==' } + }) + picker.pickImages.mockResolvedValue([ + { base64: 'YQ==', uri: 'file:///late.png' }, + { base64: 'Yg==', uri: 'file:///never.png' } + ]) + const isStart = (method: string) => + method.endsWith('uploadStart') || method === 'clipboard.startImageUpload' + sendRequest.mockImplementation(async (method, params) => { + if (isStart(method)) { + started() + await pending + return ok({ uploadId: 'held' }) + } + if (method === 'clipboard.commitImageUpload') { + return ok('/late.png') + } + if (method.endsWith('uploadCommit')) { + return ok({ path: '/late.txt', name: 'late.txt', byteLength: 1 }) + } + if (method.includes('uploadChunk') || method === 'clipboard.appendImageUploadChunk') { + return ok(null) + } + return readOrHoldReply(method, params) + }) + const inline = root() + .findByType(MobileQueuedMessageComposer) + .findByType(MobileNativeChatComposer) + await act(async () => { + if (kind === 'files') { + inline.props.structured.onAttachFiles() + } else { + inline.props.onAttachImage() + } + await start + }) + expect(readMobileNativeChatDraftInputs(scope).pending).toBe(1) + expect(state.hook!.queued.editor!.canSave).toBe(false) + expect(button('Save edited message').props.disabled).toBe(true) + expect(button('Cancel editing').props.disabled ?? false).toBe(false) + await act(async () => button('Cancel editing').props.onPress()) + expect(destination.active).toBe(false) + expect(state.hook!.queued.editor).toBeUndefined() + await act(async () => { + release() + await vi.waitFor(() => + expect( + sendRequest.mock.calls.some( + ([method]) => + method.endsWith('uploadCommit') || method === 'clipboard.commitImageUpload' + ) + ).toBe(true) + ) + }) + expect(sendRequest.mock.calls.filter(([method]) => isStart(method))).toHaveLength(1) + expect( + sendRequest.mock.calls.some(([method]) => method === 'agentSession.queuedMessageUpdate') + ).toBe(false) + expect(readMobileNativeChatDraftInputs(scope)).toMatchObject({ + files: [], + images: [], + skills: [] + }) + expect(readMobileNativeChatDraftInputs('original-bottom')).toMatchObject({ + files: [], + images: [] + }) + expect(state.bottomText).toBe('occupied') + expect(error).not.toHaveBeenCalled() + } + ) +}) diff --git a/mobile/src/session/mobile-queued-edit-hook.test-fixture.tsx b/mobile/src/session/mobile-queued-edit-hook.test-fixture.tsx new file mode 100644 index 000000000000..6b60d180a6d7 --- /dev/null +++ b/mobile/src/session/mobile-queued-edit-hook.test-fixture.tsx @@ -0,0 +1,254 @@ +import { QueuedMessageUpdateParams } from '../../../src/shared/rpc-contract/structured-agent-session-queued-edit-params' +import { useMobileNativeChatComposerMenu } from './native-chat/composer/use-mobile-native-chat-composer-menu' +import { + resetMobileNativeChatDraftStoreForTests, + useMobileNativeChatDraft +} from './native-chat/composer/mobile-native-chat-draft-store' +import { + updateMobileNativeChatDraftInputs, + resetMobileNativeChatDraftInputsForTests +} from './native-chat/composer/mobile-native-chat-draft-inputs' +import { createElement, type ReactNode } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, expect, vi } from 'vitest' +import type { + AgentSessionQueuedMessage, + AgentSessionQueuedMessageEditHoldResult +} from '../../../src/shared/agent-session-wire' +import { agentSessionSendBodyFingerprint } from '../../../src/shared/structured-agent-session-send-mutation' +import { queuedMessageWithEdit } from '../../../src/shared/queued-message-edit' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' +import { + CAPABLE, + SESSION_ID, + fieldsOf, + mutationOk, + ok, + snapshotEvent +} from './use-mobile-structured-agent-session-queued.test-fixture' + +export function withText(body: AgentSessionQueuedMessage['body'], text: string) { + const edited = queuedMessageWithEdit(body, { text }) + if (!edited.ok) { + throw new Error(edited.reason) + } + return edited.body +} + +export function editHoldReply(params: unknown): RpcResponse { + const result: AgentSessionQueuedMessageEditHoldResult = + fieldsOf(params).action === 'release' + ? { status: 'released' } + : { status: 'held', fingerprint: 'fp', leaseDurationMs: 120_000, remainingMs: 120_000 } + return ok(result) +} + +export function readOrHoldReply(method: string, params: unknown): RpcResponse { + return method === 'agentSession.queuedMessageEditHold' + ? editHoldReply(params) + : ok({ models: [], current: {} }) +} + +vi.mock('react-native', () => ({ + Text: 'Text', + View: 'View', + Pressable: 'Pressable', + ScrollView: 'ScrollView', + TextInput: 'TextInput', + ActivityIndicator: 'ActivityIndicator', + Image: 'Image', + Keyboard: { dismiss: vi.fn() }, + Platform: { OS: 'ios' }, + useWindowDimensions: () => ({ fontScale: 1 }), + StyleSheet: { create: (styles: unknown) => styles } +})) +vi.mock('lucide-react-native', () => ({ + FileText: 'FileText', + Package: 'Package', + Terminal: 'Terminal', + ArrowUp: 'ArrowUp', + AtSign: 'AtSign', + AlertCircle: 'AlertCircle', + Image: 'Image', + Paperclip: 'Paperclip', + Check: 'Check', + ChevronDown: 'ChevronDown', + ChevronLeft: 'ChevronLeft', + ChevronRight: 'ChevronRight', + CornerDownRight: 'CornerDownRight', + ListEnd: 'ListEnd', + MoreHorizontal: 'MoreHorizontal', + Mic: 'Mic', + Pencil: 'Pencil', + Play: 'Play', + Plus: 'Plus', + Send: 'Send', + ShieldAlert: 'ShieldAlert', + Square: 'Square', + Trash2: 'Trash2', + X: 'X' +})) +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: async () => null, setItem: async () => {}, removeItem: async () => {} } +})) + +export function mobileQueuedEditHarness() { + const state: { + renderer?: ReactTestRenderer + bottomText: string + hook?: ReturnType + menu?: ReturnType + listener?: (data: unknown) => void + } = { bottomText: '' } + const append = vi.fn(() => true) + const error = vi.fn() + const notice = vi.fn() + const resolved = vi.fn() + const sendRequest = vi.fn() + const client: RpcClient = { + sendRequest, + subscribe: (_method, _params, onData) => { + state.listener = onData + return () => {} + }, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + function Harness({ + sessionId = SESSION_ID, + editSupport = true, + enabled = true, + connected = true, + transport = client, + composerScopeKey, + imagesSupport = false, + skillsSupport = false, + agent = 'codex', + renderContent + }: { + sessionId?: string + editSupport?: boolean | null + enabled?: boolean + connected?: boolean + transport?: RpcClient | null + composerScopeKey?: string + imagesSupport?: boolean + skillsSupport?: boolean + agent?: string + renderContent?: (hook: ReturnType) => ReactNode + }): ReactNode { + state.bottomText = useMobileNativeChatDraft('original-bottom') + state.hook = useMobileStructuredAgentSession({ + client: transport, + sessionId, + sourceIdentity: 'host-a', + enabled, + connected, + agent, + hostSupport: + editSupport === null + ? null + : { + ...CAPABLE, + sendSkills: true, + queuedMessages: false, + queuedMessageImageEdit: imagesSupport, + queuedMessageSkillsEdit: skillsSupport, + queuedMessageEdit: editSupport + }, + composerScopeKey, + appendComposerText: append, + onSendError: error, + onQueuedEditNotice: notice, + onActionResolved: resolved + }) + state.menu = useMobileNativeChatComposerMenu({ + value: state.hook.queued.editor?.text ?? '', + onChangeText: (text) => state.hook?.queued.editor?.change(text), + agent, + structuredCatalog: { + reported: [{ name: 'review', kind: 'skill', path: '/B/SKILL.md' }], + conversationCommands: [] + }, + filePaths: [], + focusField: () => {}, + editing: true, + // Mirrors the real composer: the menu hands over its row; the draft keeps name and path. + onPickSkill: ({ token, path }) => { + const key = state.hook?.queued.editor?.scopeKey + if (key && path) { + updateMobileNativeChatDraftInputs(key, (before) => ({ + ...before, + skills: [{ name: token.slice(1), path }] + })) + } + } + }) + return renderContent?.(state.hook) ?? null + } + function requestedFingerprint(message: AgentSessionQueuedMessage) { + const params = sendRequest.mock.calls.findLast( + ([method]) => method === 'agentSession.queuedMessageUpdate' + )?.[1] + const fields = QueuedMessageUpdateParams.parse(params) + const edited = queuedMessageWithEdit(message.body, fields) + if (!edited.ok) { + throw new Error(edited.reason) + } + return agentSessionSendBodyFingerprint(SESSION_ID, edited.body) + } + async function mount( + message: AgentSessionQueuedMessage, + editSupport: boolean | null = true, + agent = 'codex', + sessionId = SESSION_ID + ) { + vi.clearAllMocks() + resetMobileNativeChatDraftInputsForTests() + resetMobileNativeChatDraftStoreForTests() + sendRequest.mockImplementation(async (method, params) => + method === 'agentSession.queuedMessageUpdate' + ? mutationOk({ + status: 'updated', + messageId: message.messageId, + fingerprint: requestedFingerprint(message) + }) + : readOrHoldReply(method, params) + ) + await act(async () => { + state.renderer = create(createElement(Harness, { editSupport, agent, sessionId })) + }) + const snapshot = snapshotEvent({ queuedMessages: [message], runningTurn: true }) + act(() => state.listener?.({ ...snapshot, sessionId, page: { ...snapshot.page, sessionId } })) + if (editSupport !== null) { + await act(async () => { + expect(await state.hook!.queued.edit!(message.messageId)).toBe(true) + }) + } + } + afterEach(async () => { + await act(async () => state.renderer?.unmount()) + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + return { + state, + Harness, + mount, + client, + sendRequest, + append, + error, + notice, + resolved, + requestedFingerprint + } +} diff --git a/mobile/src/session/mobile-queued-edit-lease-outcome.test.tsx b/mobile/src/session/mobile-queued-edit-lease-outcome.test.tsx new file mode 100644 index 000000000000..a123e3fd80a9 --- /dev/null +++ b/mobile/src/session/mobile-queued-edit-lease-outcome.test.tsx @@ -0,0 +1,366 @@ +import { createElement } from 'react' +import { setMobileNativeChatDraftText } from './native-chat/composer/mobile-native-chat-draft-store' +import { + readMobileNativeChatDraftInputs, + updateMobileNativeChatDraftInputs +} from './native-chat/composer/mobile-native-chat-draft-inputs' +import { act } from 'react-test-renderer' +import { beforeAll, describe, expect, it, vi } from 'vitest' +import type { QueuedEditHostFixtureModule } from '../../../src/shared/queued-edit-host.test-fixture' +import type { AgentSessionQueuedMessageEditHoldResult } from '../../../src/shared/agent-session-wire' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { mobileQueuedEditHarness, readOrHoldReply } from './mobile-queued-edit-hook.test-fixture' +import { + fieldsOf, + mutationOk, + ok, + queuedDraft, + SESSION_ID +} from './use-mobile-structured-agent-session-queued.test-fixture' + +let queuedEditHost: QueuedEditHostFixtureModule['queuedEditHost'] +beforeAll(async () => { + const fixtures = await vi.importActual( + '../../../src/main/native-chat/agent-session-journal/queued-edit-host.test-fixture' + ) + queuedEditHost = fixtures.queuedEditHost +}) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((accept) => { + resolve = accept + }) + return { promise, resolve } +} + +describe('mobile current acquisition belongs to the Save outcome owner', () => { + const { state, Harness, mount, sendRequest, notice, append, resolved } = mobileQueuedEditHarness() + it.each([ + 'updated', + 'unchanged', + 'newer', + 'fresh-loss', + 'gone', + 'refused', + 'unknown', + 'late-unknown', + 'unresolved', + 'depart', + 'depart-equal' + ] as const)( + '%s does not return accepted current input before the delayed result', + async (mode) => { + const message = queuedDraft({ messageId: 'lease-race' }) + const host = queuedEditHost(SESSION_ID, message) + await mount(message) + act(() => state.hook!.queued.editor!.cancel()) + const loss = deferred() + const freshLoss = deferred() + const release = deferred() + let expected = '' + let editId = '' + let acquires = 0 + sendRequest.mockImplementation(async (method, params) => { + const fields = fieldsOf(params) + if (method === 'agentSession.queuedMessageEditHold' && fields.action === 'acquire') { + acquires++ + expected = String(fields.expectedBodyFingerprint) + editId = String(fields.editId) + return ok( + acquires === 1 + ? await loss.promise + : mode === 'fresh-loss' + ? await freshLoss.promise + : host.acquire(editId, expected) + ) + } + if (method !== 'agentSession.queuedMessageUpdate') { + return readOrHoldReply(method, params) + } + await release.promise + if (mode === 'unknown' || mode === 'late-unknown') { + throw markRpcDeliveryUnknown(new Error('lost')) + } + return mutationOk( + mode === 'refused' ? { status: 'not-editable', messageId: message.messageId } : decision + ) + }) + await act(async () => { + await state.hook!.queued.edit!(message.messageId) + }) + act(() => state.hook!.queued.editor!.change('B')) + if (mode === 'unchanged') { + host.update({ + messageId: message.messageId, + expectedBodyFingerprint: host.row!.fingerprint, + text: 'B' + }) + } + let save!: Promise + act(() => { + save = state.hook!.queued.editor!.save() + }) + const decision = host.update({ + messageId: message.messageId, + expectedBodyFingerprint: expected, + text: 'B' + }) + expect(decision.status).toBe(mode === 'unchanged' ? 'unchanged' : 'updated') + if (mode === 'newer' || mode === 'fresh-loss' || mode === 'gone' || mode === 'depart') { + act(() => { + state.hook!.queued.editor!.insertDictation!('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') { + act(() => state.renderer!.unmount()) + state.renderer = undefined + } + if (mode !== 'late-unknown' && mode !== 'unresolved') { + await act(async () => loss.resolve(negative)) + if (mode !== 'depart' && mode !== 'depart-equal') { + expect(state.hook!.queued.editor?.saving).toBe(true) + } + expect(append).not.toHaveBeenCalled() + expect(notice).not.toHaveBeenCalled() + } + await act(async () => { + release.resolve() + await save + }) + if (mode === 'late-unknown') { + expect(state.hook!.queued.editor).toMatchObject({ saving: false }) + await act(async () => loss.resolve(negative)) + } + if (mode === 'newer' || mode === 'fresh-loss') { + expect(state.hook!.queued.editor).toMatchObject({ text: 'B C', saving: false }) + expect(append).not.toHaveBeenCalled() + expect(acquires).toBe(2) + expect(resolved).not.toHaveBeenCalled() + if (mode === 'fresh-loss') { + host.depart() + await act(async () => freshLoss.resolve(host.acquire(editId, expected))) + expect(state.hook!.queued.editor).toBeUndefined() + expect(append).toHaveBeenCalledWith('B C') + expect(notice).toHaveBeenCalledOnce() + } else { + act(() => state.hook!.queued.editor!.cancel()) + expect(state.bottomText).toBe('') + } + } else if ( + mode === 'gone' || + mode === 'refused' || + mode === 'unknown' || + mode === 'late-unknown' || + mode === 'depart' + ) { + if (mode !== 'depart') { + expect(state.hook!.queued.editor).toBeUndefined() + } + expect(append).toHaveBeenCalledOnce() + expect(append).toHaveBeenCalledWith(mode === 'gone' || mode === 'depart' ? 'B C' : 'B') + expect(notice).toHaveBeenCalledOnce() + } else { + if (mode !== 'depart-equal') { + expect(state.hook!.queued.editor).toBeUndefined() + } + expect(append).not.toHaveBeenCalled() + expect(notice).not.toHaveBeenCalled() + if (mode !== 'depart-equal') { + expect(resolved).toHaveBeenCalledOnce() + } + } + if (mode === 'unresolved') { + await act(async () => loss.resolve(negative)) + } + expect(append).toHaveBeenCalledTimes( + ['gone', 'refused', 'unknown', 'late-unknown', 'depart', 'fresh-loss'].includes(mode) + ? 1 + : 0 + ) + host.leases.dispose() + } + ) + it.each(['unknown-changed', 'unknown-gone', 'pending-proof', 'rebase-loss'] as const)( + '%s delivers timed terminal loss through the actual phone lease into its owner', + async (mode) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const message = queuedDraft({ messageId: 'timed' }) + const host = queuedEditHost(SESSION_ID, message) + await mount(message) + await act(async () => state.hook!.queued.editor!.cancel()) + const actions: string[] = [] + const reply = deferred() + let decision: ReturnType + sendRequest.mockImplementation(async (method, params) => { + if (method === 'agentSession.queuedMessageEditHold') { + const fields = fieldsOf(params) + const editId = String(fields.editId) + const key = { callerKey: 'client', editId } + actions.push(String(fields.action)) + if (fields.action === 'release') { + host.leases.release(key, message.messageId) + return ok({ status: 'released' }) + } + return ok( + fields.action === 'acquire' + ? host.acquire(editId, String(fields.expectedBodyFingerprint)) + : host.leases.renew(key, message.messageId) + ) + } + if (method !== 'agentSession.queuedMessageUpdate') { + return readOrHoldReply(method, params) + } + decision = host.update({ + messageId: message.messageId, + expectedBodyFingerprint: host.row!.fingerprint, + text: '$review B' + }) + expect(decision.status).toBe('updated') + await reply.promise + if (mode.startsWith('unknown')) { + throw markRpcDeliveryUnknown(new Error('lost')) + } + return mutationOk(decision) + }) + await act(async () => + state.renderer!.update( + createElement(Harness, { + composerScopeKey: 'original-bottom', + imagesSupport: true, + skillsSupport: true + }) + ) + ) + await act(async () => { + await state.hook!.queued.edit!(message.messageId) + }) + act(() => setMobileNativeChatDraftText('original-bottom', 'occupied')) + act(() => state.hook!.queued.editor!.change('$review B')) + let save!: Promise + await act(async () => { + save = state.hook!.queued.editor!.save() + }) + const scope = state.hook!.queued.editor!.scopeKey + if (mode === 'rebase-loss') { + act(() => { + state.hook!.queued.editor!.insertDictation!('C') + updateMobileNativeChatDraftInputs(scope, (input) => ({ + ...input, + skills: [{ name: 'review', path: '/C/SKILL.md' }], + images: [{ id: 'new-C', path: '/C.png', previewUri: 'file:///C.png' }] + })) + }) + } + if (mode !== 'pending-proof') { + await act(async () => { + reply.resolve() + await save + }) + } + if (mode === 'unknown-gone') { + host.depart() + } + if (mode === 'rebase-loss') { + expect(state.hook!.queued.editor).toMatchObject({ text: '$review B C', saving: false }) + expect(state.bottomText).toBe('occupied') + expect(host.leases.heldIds()).toEqual(new Set([message.messageId])) + host.update({ + messageId: message.messageId, + expectedBodyFingerprint: host.row!.fingerprint, + text: 'rival' + }) + } + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000) + }) + if (mode === 'pending-proof') { + expect(state.hook!.queued.editor?.saving).toBe(true) + expect(notice).not.toHaveBeenCalled() + expect(state.bottomText).toBe('occupied') + await act(async () => { + reply.resolve() + await save + }) + } + expect(state.hook!.queued.editor).toBeUndefined() + expect(state.bottomText).toBe( + mode === 'pending-proof' + ? 'occupied' + : `occupied\n\n$review B${mode === 'rebase-loss' ? ' C' : ''}` + ) + expect(notice).toHaveBeenCalledTimes(mode === 'pending-proof' ? 0 : 1) + if (mode === 'rebase-loss') { + expect(readMobileNativeChatDraftInputs('original-bottom')).toMatchObject({ + skills: [{ name: 'review', path: '/C/SKILL.md' }], + images: [{ id: 'new-C', path: '/C.png' }] + }) + } + expect(actions).toEqual( + mode === 'rebase-loss' + ? ['acquire', 'acquire', 'release', 'renew', 'acquire', 'release'] + : ['acquire', 'renew', 'acquire', 'release'] + ) + const count = sendRequest.mock.calls.length + await act(async () => { + await vi.advanceTimersByTimeAsync(120_000) + }) + expect(sendRequest.mock.calls).toHaveLength(count) + host.leases.dispose() + } + ) + + it.each(['gone', 'held'] as const)( + 'initial phone transport failure then timed %s preserves owner outcomes', + async (status) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const message = queuedDraft({ messageId: 'retry' }) + const host = queuedEditHost(SESSION_ID, message) + await mount(message) + act(() => state.hook!.queued.editor!.cancel()) + let first = true + sendRequest.mockImplementation(async (method, params) => { + const fields = fieldsOf(params) + if (method !== 'agentSession.queuedMessageEditHold') { + return readOrHoldReply(method, params) + } + if (fields.action === 'release') { + return ok({ status: 'released' }) + } + if (first) { + first = false + throw new Error('offline') + } + return ok(host.acquire(String(fields.editId), String(fields.expectedBodyFingerprint))) + }) + await act(async () => { + await state.hook!.queued.edit!(message.messageId) + }) + act(() => state.hook!.queued.editor!.change('B')) + if (status === 'gone') { + host.depart() + } + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000) + }) + if (status === 'gone') { + expect(state.hook!.queued.editor).toBeUndefined() + expect(append).toHaveBeenCalledOnce() + expect(append).toHaveBeenCalledWith('B') + expect(notice).toHaveBeenCalledOnce() + } else { + expect(state.hook!.queued.editor).toMatchObject({ text: 'B', saving: false }) + expect(append).not.toHaveBeenCalled() + act(() => state.hook!.queued.editor!.cancel()) + } + host.leases.dispose() + warn.mockRestore() + } + ) +}) diff --git a/mobile/src/session/mobile-queued-edit-presentation.test.tsx b/mobile/src/session/mobile-queued-edit-presentation.test.tsx new file mode 100644 index 000000000000..f41587687c28 --- /dev/null +++ b/mobile/src/session/mobile-queued-edit-presentation.test.tsx @@ -0,0 +1,594 @@ +import type { NativeChatSkillDiscoverySnapshot } from '../../../src/shared/native-chat/input/native-chat-picker-items' +import type { QueuedEditHostFixtureModule } from '../../../src/shared/queued-edit-host.test-fixture' +import type { AcpCommandOptionsFixtureModule } from '../../../src/shared/mobile-queued-edit-host.test-fixture-contract' +import { QueuedMessageUpdateParams } from '../../../src/shared/rpc-contract/structured-agent-session-queued-edit-params' +import { mobileQueuedEditHarness, readOrHoldReply } from './mobile-queued-edit-hook.test-fixture' +import { createElement, type ReactNode } from 'react' +import { act } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' +import { ScrollView, TextInput } from 'react-native' +import { ActionSheetModal } from '../components/ActionSheetModal' +import { MobileQueuedMessageComposerContext } from './mobile-queued-message-composer-context' +import { MobileNativeChatQueuedMessages } from './native-chat/input/MobileNativeChatQueuedMessages' +import { + MobileNativeChatComposer, + MobileNativeChatDraftComposer +} from './native-chat/composer/MobileNativeChatComposer' +import { MobileNativeChatComposerMenu } from './native-chat/composer/MobileNativeChatComposerMenu' +import { MobileQueuedMessageComposer } from './MobileQueuedMessageComposer' +import { useMobileNativeChatDrafts } from './native-chat/composer/use-mobile-native-chat-drafts' +import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge' +import { mobileNativeChatScopeKey } from './native-chat/mobile-native-chat-scope-key' +import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture' +import { useMobileNativeChatFileAttachments } from './native-chat/composer/use-mobile-native-chat-file-attachments' +import { useMobileNativeChatImageAttachments } from './native-chat/composer/use-mobile-native-chat-image-attachments' +import { + useMobileNativeChatDraft, + setMobileNativeChatDraftText +} from './native-chat/composer/mobile-native-chat-draft-store' +import { + readMobileNativeChatDraftInputs, + updateMobileNativeChatDraftInputs +} from './native-chat/composer/mobile-native-chat-draft-inputs' +import { + queuedDraft, + SESSION_ID, + mutationOk +} from './use-mobile-structured-agent-session-queued.test-fixture' + +const picker = vi.hoisted(() => ({ pickFiles: vi.fn(), pickImages: vi.fn() })) +vi.mock('../platform/media-picker', () => ({ useMediaPicker: () => picker })) +vi.mock('../components/DragReorderList', () => ({ DragReorderList: 'DragReorderList' })) +vi.mock('react-native-reanimated', async () => { + const { useRef } = await import('react') + return { + default: { ScrollView: 'ScrollView' }, + useAnimatedRef: () => useRef(null), + useSharedValue: (value: unknown) => useRef({ value }).current + } +}) +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) +vi.mock('../components/mounted-bottom-drawer', () => ({ MountedBottomDrawer: 'BottomDrawer' })) +vi.mock('../components/bottom-drawer-modal-host-scope', () => ({ + useBottomDrawerModalHost: () => null +})) + +const { queuedEditHost } = await vi.importActual( + '../../../src/main/native-chat/agent-session-journal/queued-edit-host.test-fixture' +) +const { AcpStructuredOptions } = await vi.importActual( + '../../../src/main/acp/acp-structured-options' +) + +describe('phone inline editor presentation', () => { + const { state, Harness, mount, sendRequest, error, resolved, client } = mobileQueuedEditHarness() + async function rows( + message: Parameters[0], + agent = 'codex', + sessionId = SESSION_ID, + catalog: { + reported: { name: string; kind: 'skill' | 'command'; path?: string }[] + discovery?: NativeChatSkillDiscoverySnapshot + } = { + reported: [] + }, + extra?: (hook: NonNullable) => ReactNode, + composerScopeKey = 'original-bottom' + ) { + await mount(message, true, agent, sessionId) + act(() => state.hook!.queued.editor!.cancel()) + resolved.mockClear() + const renderContent: NonNullable[0]['renderContent']> = (hook) => + createElement( + MobileQueuedMessageComposerContext.Provider, + { + value: { + agent, + structured: { + catalog: { reported: catalog.reported, conversationCommands: [] }, + onSlashMenuOpen: () => {}, + files: [], + onRemoveFile: () => {} + }, + skillDiscovery: catalog.discovery, + filePaths: [], + onNeedFiles: undefined, + onError: error, + onMicPress: undefined, + micActive: false, + dictationMode: undefined, + onMicPressIn: () => {}, + onMicPressOut: () => {} + } + }, + createElement(MobileNativeChatQueuedMessages, { + cards: hook.queued.cards, + onEdit: hook.queued.edit, + editor: hook.queued.editor + }), + extra?.(hook) + ) + const rerender = async () => + act(async () => + state.renderer!.update( + createElement(Harness, { + sessionId, + agent, + renderContent, + skillsSupport: true, + imagesSupport: true, + composerScopeKey + }) + ) + ) + await rerender() + const root = () => state.renderer!.root + const button = (label: string) => + root().findAll( + (node) => + node.props.accessibilityLabel === label && typeof node.props.onPress === 'function' + )[0]! + const field = () => + root() + .findByType(MobileQueuedMessageComposer) + .find((node) => node.type === TextInput && node.props.multiline === true) + const open = async () => { + await act(async () => button('More actions').props.onPress()) + const sheet = root().find( + (node) => + node.type === ActionSheetModal && node.props.actions?.[0]?.label === 'Edit message' + ) + await act(async () => sheet.props.actions[0].onPress()) + expect(root().findAllByType(MobileQueuedMessageComposer)).toHaveLength(1) + expect(root().findAllByType(MobileNativeChatComposer)).toHaveLength(extra ? 2 : 1) + } + await open() + return { root, button, field, rerender, open } + } + it('gives the short image editor and open picker the available queue scroll space, separate from the ordinary draft', async () => { + const message = queuedDraft({ + messageId: 'photo-layout', + body: { + kind: 'message', + role: 'user', + blocks: [ + { type: 'text', text: 'short edit' }, + { type: 'image-ref', url: 'https://host/photo.png', alt: 'photo' } + ] + } + }) + const catalog = { + reported: Array.from({ length: 6 }, (_, i) => ({ + name: `skill-${i}`, + kind: 'skill' as const, + path: `/skills/${i}/SKILL.md` + })) + } + const { root, button } = await rows(message, 'codex', SESSION_ID, catalog, () => + createElement(MobileNativeChatDraftComposer, { + draftKey: 'original-bottom', + draftScopeKey: 'original-bottom', + agent: 'codex', + structured: { + catalog: { ...catalog, conversationCommands: [] }, + onSlashMenuOpen: () => {}, + files: [], + onRemoveFile: () => {} + }, + onChangeText: (text) => setMobileNativeChatDraftText('original-bottom', text), + onSend: async () => true, + sendSurfaceId: 'original-bottom', + getSendCompletionGeneration: () => 0, + getComposerEditGeneration: () => 0 + }) + ) + const { snapshotEvent } = + await import('./use-mobile-structured-agent-session-queued.test-fixture') + act(() => { + setMobileNativeChatDraftText('original-bottom', 'Bottom draft stays here.') + state.listener?.( + snapshotEvent({ + queuedMessages: [message, queuedDraft({ messageId: 'neighbor', position: 2 })], + runningTurn: true + }) + ) + }) + const style = (value: unknown): Record => Object.assign({}, ...[value].flat()) + const queue = () => root().findByType(MobileNativeChatQueuedMessages) + const scroll = () => + queue().find((node) => node.type === ScrollView && node.props.onContentSizeChange) + const box = () => root().findByProps({ testID: 'queued-box' }) + const editor = () => root().findByType(MobileQueuedMessageComposer) + const bottom = () => root().findByType(MobileNativeChatDraftComposer) + expect(editor().findByType(MobileNativeChatComposer).props.attachments).toHaveLength(1) + expect(queue().findAllByProps({ testID: 'queued-card-row' })).toHaveLength(2) + // Every boundary down to the scrollport must yield to the keyboard/bottom siblings. + for (const node of [box().parent!, box(), scroll().parent!, scroll()]) { + expect(style(node.props.style)).toMatchObject({ flexShrink: 1, minHeight: 0 }) + } + expect(style(scroll().props.style).maxHeight).toBeUndefined() + expect(style(box().parent!.props.style).marginBottom).toBe(0) + expect(style(box().props.style).paddingBottom).toBe(0) + expect(queue().findAllByType(MobileNativeChatDraftComposer)).toHaveLength(0) + const field = editor().findByType(TextInput) + act(() => { + field.props.onChangeText('/') + field.props.onSelectionChange({ nativeEvent: { selection: { start: 1, end: 1 } } }) + }) + const menuScroll = root().findByType(MobileNativeChatComposerMenu).findByType(ScrollView) + expect(style(menuScroll.props.style).maxHeight).toBe(300) + expect(menuScroll.props.nestedScrollEnabled).toBe(true) + expect(menuScroll.props.keyboardShouldPersistTaps).toBe('always') + const scrollTo = vi.fn() + scroll().props.ref.current = { scrollTo, flashScrollIndicators: vi.fn() } + act(() => { + scroll().props.onLayout({ nativeEvent: { layout: { height: 280 } } }) + scroll().props.onContentSizeChange(360, 800) + editor().parent!.props.onLayout({ + nativeEvent: { layout: { x: 0, y: 0, width: 360, height: 620 } } + }) + }) + expect(scrollTo).toHaveBeenCalledExactlyOnceWith({ y: 0, animated: false }) + act(() => scroll().props.onScroll({ nativeEvent: { contentOffset: { y: 120 } } })) + expect(scrollTo).toHaveBeenCalledTimes(1) + await act(async () => button('$skill-5').props.onPress()) + expect(state.hook!.queued.editor?.text).toBe('$skill-5 ') + expect(readMobileNativeChatDraftInputs(state.hook!.queued.editor!.scopeKey).skills).toEqual([ + { name: 'skill-5', path: '/skills/5/SKILL.md' } + ]) + expect(editor().findByType(MobileNativeChatComposer).props.attachments).toHaveLength(1) + expect(bottom().findByType(TextInput).props.value).toBe('Bottom draft stays here.') + await act(async () => button('Cancel editing').props.onPress()) + expect(style(scroll().props.style).maxHeight).toBe(160) + expect(style(box().parent!.props.style).marginBottom).toBeLessThan(0) + expect(bottom().findByType(TextInput).props.value).toBe('Bottom draft stays here.') + act(() => { + bottom().findByType(TextInput).props.onChangeText('/') + bottom() + .findByType(TextInput) + .props.onSelectionChange({ nativeEvent: { selection: { start: 1, end: 1 } } }) + }) + const ordinaryMenu = bottom().findByType(MobileNativeChatComposerMenu).findByType(ScrollView) + expect(style(ordinaryMenu.props.style).maxHeight).toBe(300) + expect(ordinaryMenu.props.nestedScrollEnabled).toBeFalsy() + expect( + sendRequest.mock.calls.some(([method]) => method === 'agentSession.queuedMessageUpdate') + ).toBe(false) + }) + it('hands back into the main captured Send path, keeps pending files, and restores a rejected complete draft', async () => { + const { snapshotEvent, ok, fieldsOf } = + await import('./use-mobile-structured-agent-session-queued.test-fixture') + const scope = mobileNativeChatScopeKey('host', 'worktree', 'handoff')! + const message = queuedDraft({ + messageId: 'pending-files', + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '$review intent' }], + skills: [{ name: 'review', path: '/edit/SKILL.md' }] + } + }) + function Bottom({ session }: { session: NonNullable }) { + const drafts = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId: 'handoff', + sessionId: SESSION_ID, + messages: [], + transcriptSettled: true + }) + const bridge = useMobileStructuredNativeChatSendBridge({ + ...drafts, + agent: 'codex', + sendStructured: session.sendWithOutcome, + onSendError: error + }) + const text = useMobileNativeChatDraft(scope) + const files = useMobileNativeChatFileAttachments({ + client, + sessionId: SESSION_ID, + scopeKey: scope, + enabled: true, + showToast: error + }) + const images = useMobileNativeChatImageAttachments({ + client, + agent: 'codex', + activeHandleRef: { current: null }, + deviceTokenRef: { current: null }, + getActiveWorktreeConnectionId: async () => null, + connState: 'connected', + scopeKey: scope, + enabled: true, + structuredNativeChat: true, + showToast: error, + onSendError: error, + baseSend: bridge.sendWithOutcome, + readSeededLaunchDraft: () => null, + files: files.files, + onFilesSent: files.removeFiles + }) + return createElement(MobileNativeChatComposer, { + value: text, + agent: 'codex', + draftScopeKey: scope, + onChangeText: drafts.setComposerText, + onSend: images.sendNativeChat, + sendSurfaceId: scope, + getSendCompletionGeneration: () => 0, + getComposerEditGeneration: drafts.getComposerEditGeneration, + attachments: images.attachments, + structured: { + catalog: { reported: [], conversationCommands: [] }, + onSlashMenuOpen: () => {}, + files: files.files, + onRemoveFile: () => {} + } + }) + } + const { root, button, field } = await rows( + message, + 'codex', + SESSION_ID, + { reported: [{ name: 'later', kind: 'skill', path: '/later/SKILL.md' }] }, + (hook) => createElement(Bottom, { session: hook }), + scope + ) + act(() => { + field().props.onChangeText('$review intent then $la') + field().props.onSelectionChange({ nativeEvent: { selection: { start: 23, end: 23 } } }) + }) + await act(async () => button('$later').props.onPress()) + act(() => { + setMobileNativeChatDraftText(scope, '$prior occupied') + updateMobileNativeChatDraftInputs(scope, () => ({ + images: [{ id: 'prior-image', path: '/prior.png', previewUri: 'file:///prior.png' }], + files: [ + { + id: 'prior-file', + path: '/prior.txt', + name: 'prior.txt', + mime: 'text/plain', + byteLength: 1 + } + ], + skills: [{ name: 'prior', path: '/prior/SKILL.md' }] + })) + updateMobileNativeChatDraftInputs(state.hook!.queued.editor!.scopeKey, (input) => ({ + ...input, + images: [{ id: 'edit-image', path: '/added.png', previewUri: 'file:///added.png' }] + })) + }) + let release = () => {} + const held = new Promise((resolve) => { + release = resolve + }) + let started = () => {} + const start = new Promise((resolve) => { + started = resolve + }) + picker.pickFiles.mockImplementation(async function* () { + yield { name: 'late.txt', mime: 'text/plain', byteLength: 1, base64: 'YQ==' } + }) + let rejected = false + sendRequest.mockImplementation(async (method, params) => { + if (method.endsWith('uploadStart')) { + started() + await held + return ok({ uploadId: 'pending-upload' }) + } + if (method.endsWith('uploadCommit')) { + return ok({ path: '/late.txt', name: 'late.txt', byteLength: 1 }) + } + if (method.endsWith('uploadChunk')) { + return ok(null) + } + if (method === 'agentSession.send') { + if (!rejected) { + rejected = true + return { + id: 'send-refusal', + ok: false, + error: { code: 'unauthorized', message: 'Send refused before execution' } + } + } + return mutationOk(structuredSendResultFixture('accepted')) + } + return readOrHoldReply(method, params) + }) + const inline = root() + .findByType(MobileQueuedMessageComposer) + .findByType(MobileNativeChatComposer) + await act(async () => { + inline.props.structured.onAttachFiles() + await start + }) + const beforeBottom = root() + .findAllByType(MobileNativeChatComposer) + .find((node) => node.props.draftScopeKey === scope)! + act(() => state.listener?.(snapshotEvent({ queuedMessages: [] }))) + expect(root().findByType(MobileNativeChatComposer)).toBe(beforeBottom) + expect(state.hook!.queued.editor).toBeUndefined() + const pressSend = () => + root() + .findAll( + (node) => + node.props.accessibilityLabel === 'Send message' && + typeof node.props.onPress === 'function' + )[0]! + .props.onPress() + await act(async () => pressSend()) + expect(sendRequest.mock.calls.some(([method]) => method === 'agentSession.send')).toBe(false) + await act(async () => { + release() + await vi.waitFor(() => expect(readMobileNativeChatDraftInputs(scope).pending ?? 0).toBe(0)) + }) + await act(async () => pressSend()) + expect(root().findByType(MobileNativeChatComposer)).toBe(beforeBottom) + expect(root().findByType(TextInput).props.value).toBe( + '$prior occupied\n\n$review intent then $later ' + ) + expect(readMobileNativeChatDraftInputs(scope).skills).toEqual([ + { name: 'prior', path: '/prior/SKILL.md' }, + { name: 'review', path: '/edit/SKILL.md' }, + { name: 'later', path: '/later/SKILL.md' } + ]) + await act(async () => pressSend()) + const requests = sendRequest.mock.calls.filter(([method]) => method === 'agentSession.send') + expect(requests).toHaveLength(2) + expect(requests[1]![1]).toMatchObject({ body: fieldsOf(requests[0]![1]).body }) + expect(requests[0]![1]).toMatchObject({ + body: { + blocks: [ + { + type: 'text', + text: '$prior occupied\n\n$review intent then $later\n@/prior.txt @/late.txt' + }, + { type: 'image-ref', path: '/prior.png' }, + { type: 'image-ref', path: '/added.png' } + ], + skills: [ + { name: 'prior', path: '/prior/SKILL.md' }, + { name: 'review', path: '/edit/SKILL.md' }, + { name: 'later', path: '/later/SKILL.md' } + ] + } + }) + expect(readMobileNativeChatDraftInputs(scope).files).toEqual([]) + expect(readMobileNativeChatDraftInputs(scope).images).toEqual([]) + }) + + it('refuses a stale control pick but saves a typed namesake literally when its menu is closed', async () => { + const message = queuedDraft({ messageId: 'namesake' }) + const { root, button, field } = await rows(message, 'claude', SESSION_ID, { + reported: [{ name: 'model', kind: 'skill', path: '/A/SKILL.md' }] + }) + act(() => field().props.onChangeText('/')) + act(() => field().props.onSelectionChange({ nativeEvent: { selection: { start: 1, end: 1 } } })) + const stale = root().findByType(MobileNativeChatComposerMenu).props.onPick + await act(async () => button('/model').props.onPress()) + const key = state.hook!.queued.editor!.scopeKey + expect(readMobileNativeChatDraftInputs(key).skills).toHaveLength(1) + act(() => stale({ kind: 'command', token: '/model', name: 'model', id: 'command:model' })) + expect(error).toHaveBeenLastCalledWith("Commands can't be saved in a queued message.") + expect(state.hook!.queued.editor!.text).toBe('/model ') + act(() => field().props.onChangeText('')) + act(() => field().props.onChangeText('/model literal')) + expect(readMobileNativeChatDraftInputs(key).skills).toEqual([]) + act(() => + field().props.onSelectionChange({ nativeEvent: { selection: { start: 14, end: 14 } } }) + ) + expect(root().findAllByType(MobileNativeChatComposerMenu)).toHaveLength(0) + await act(async () => button('Save edited message').props.onPress()) + expect(error).toHaveBeenCalledOnce() + expect( + sendRequest.mock.calls.filter(([method]) => method === 'agentSession.queuedMessageUpdate') + ).toHaveLength(1) + expect( + sendRequest.mock.calls.some(([method]) => + /(?:\.send$|conversationCommand|goal|resume|pty)/i.test(method) + ) + ).toBe(false) + expect(state.hook!.queued.editor).toBeUndefined() + }) + + it('an image-only attributed card reaches the actual inline composer and no-op Save retains its image and sender', async () => { + const message = queuedDraft({ + messageId: 'photo-only', + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'image-ref', url: 'https://host/photo.png', alt: 'photo' }], + from: { kind: 'agent', senders: [], orchestration: null } + } + }) + const { root, button } = await rows(message) + expect(root().findByType(MobileNativeChatComposer).props.attachments).toHaveLength(1) + await act(async () => button('Save edited message').props.onPress()) + expect(state.hook!.queued.editor).toBeUndefined() + expect( + sendRequest.mock.calls.some(([method]) => method === 'agentSession.queuedMessageUpdate') + ).toBe(false) + expect(state.hook!.queued.cards[0]?.images).toEqual(message.body.blocks) + expect(state.hook!.queued.cards[0]?.attribution).not.toBeNull() + expect(state.bottomText).toBe('') + }) + it('saves a scanned model skill picked through More and Edit without dispatching a host command', async () => { + const options = new AcpStructuredOptions() + options.adoptCommands([{ name: 'model', description: 'Choose the model' }]) + const reported = options.readCommands() ?? [] + expect(reported).toEqual([{ name: 'model', kind: 'command', description: 'Choose the model' }]) + const message = queuedDraft({ + messageId: 'scanned-model', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'seed' }] } + }) + const host = queuedEditHost(SESSION_ID, message) + try { + const { root, button, field } = await rows(message, 'opencode2', SESSION_ID, { + reported, + discovery: { + status: 'ready', + skills: [ + { + id: 'repo:model', + name: 'model', + description: 'Explain the domain model', + providers: ['agent-skills'], + sourceKind: 'repo', + sourceLabel: 'Repo', + rootPath: '/workspace/.agents/skills', + directoryPath: '/workspace/.agents/skills/model', + skillFilePath: '/workspace/.agents/skills/model/SKILL.md', + installed: true, + updatedAt: null + } + ] + } + }) + sendRequest.mockImplementation(async (method, params) => + method === 'agentSession.queuedMessageUpdate' + ? mutationOk(host.update(QueuedMessageUpdateParams.parse(params))) + : readOrHoldReply(method, params) + ) + act(() => field().props.onChangeText('/')) + act(() => + field().props.onSelectionChange({ nativeEvent: { selection: { start: 1, end: 1 } } }) + ) + const menu = root().findByType(MobileNativeChatComposerMenu) + expect(menu.props.rows.filter((row: { token?: string }) => row.token === '/model')).toEqual([ + expect.objectContaining({ + kind: 'skill', + id: 'skill:model', + token: '/model', + sources: [ + { sourceKind: 'repo', skillFilePath: '/workspace/.agents/skills/model/SKILL.md' } + ] + }) + ]) + expect( + menu.props.rows.find((row: { token?: string }) => row.token === '/model') + ).not.toHaveProperty('path') + await act(async () => button('/model').props.onPress()) + expect(state.hook!.queued.editor?.text).toBe('/model ') + expect(readMobileNativeChatDraftInputs(state.hook!.queued.editor!.scopeKey).skills).toEqual( + [] + ) + expect(error.mock.calls).toEqual([]) + await act(async () => button('Save edited message').props.onPress()) + expect( + sendRequest.mock.calls.some(([method]) => + /(?:\.send$|conversationCommand|goal|resume|pty|setOption)/i.test(method) + ) + ).toBe(false) + expect(error.mock.calls).toEqual([]) + expect( + sendRequest.mock.calls.filter(([method]) => method === 'agentSession.queuedMessageUpdate') + ).toHaveLength(1) + expect(state.hook!.queued.editor).toBeUndefined() + expect(host.row!.body.blocks).toEqual([{ type: 'text', text: '/model' }]) + expect(host.row!.body.skills).toBeUndefined() + } finally { + host.leases.dispose() + } + }) +}) diff --git a/mobile/src/session/mobile-queued-edit-trigger-scaling.test.tsx b/mobile/src/session/mobile-queued-edit-trigger-scaling.test.tsx new file mode 100644 index 000000000000..1627fbecc442 --- /dev/null +++ b/mobile/src/session/mobile-queued-edit-trigger-scaling.test.tsx @@ -0,0 +1,136 @@ +import { mobileQueuedEditHarness, withText } from './mobile-queued-edit-hook.test-fixture' +import { countedQueuedEditPublication } from '../../../src/shared/queued-edit-publication.test-fixture' +import * as publication from '../../../src/shared/queued-message-edit-publication' +import * as eligibility from '../../../src/shared/queued-message-edit-command' +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { afterEach, expect, it, vi } from 'vitest' +import { + queuedDraft, + snapshotEvent +} from './use-mobile-structured-agent-session-queued.test-fixture' +import { useMobileNativeChatComposerMenu } from './native-chat/composer/use-mobile-native-chat-composer-menu' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import type { MobileComposerStructuredCatalog } from './native-chat/composer/mobile-native-chat-composer-menu' +afterEach(() => vi.restoreAllMocks()) +const { state, mount, sendRequest } = mobileQueuedEditHarness() +it.each([10, 100])( + 'actual phone 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 message = queuedDraft({ messageId: 'target' }) + await mount(message) + act(() => state.hook!.queued.editor!.cancel()) + const rows = () => [ + ...Array.from({ length: n - 1 }, (_, i) => queuedDraft({ messageId: `other-${i}` })), + message + ] + const submissions = () => + Array.from({ length: n }, (_, i) => ({ + clientMessageId: `s${i}`, + queuedMessageId: `unused-${i}`, + fence: 3, + payloadFingerprint: 'other', + dispatchState: 'pending' as const, + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + })) + counter.reset() + act(() => + state.listener?.(snapshotEvent({ queuedMessages: rows(), submissions: submissions() })) + ) + expect(counter.reads).toBe(0) + await act(async () => { + await state.hook!.queued.edit!('target') + }) + counter.reset() + act(() => + state.listener?.(snapshotEvent({ queuedMessages: rows(), submissions: submissions() })) + ) + expect(counter.reads).toBeGreaterThanOrEqual(3 * n) + expect(counter.reads).toBeLessThanOrEqual(3 * n + 2) + expect(state.hook!.queued.editor).toBeDefined() + act(() => state.hook!.queued.editor!.change('saved')) + sendRequest.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('lost'))) + await act(async () => { + await state.hook!.queued.editor!.save() + }) + counter.reset() + act(() => + state.listener?.( + snapshotEvent({ + queuedMessages: [ + ...rows().slice(0, -1), + { ...message, body: withText(message.body, 'saved') } + ], + submissions: submissions() + }) + ) + ) + expect(counter.reads).toBeLessThanOrEqual(3 * n + 2) + expect(state.hook!.queued.editor).toBeUndefined() + } +) +it.each([10, 100])( + 'actual phone menu reuses one eligibility preparation across query/all-token paths for %i rows', + async (n) => { + const prepare = vi.spyOn(eligibility, 'prepareQueuedMessageEditText') + const reported = () => + Array.from({ length: n }, (_, i) => ({ name: `provider-${i}`, kind: 'command' as const })) + const catalog: MobileComposerStructuredCatalog = { + reported: reported(), + conversationCommands: [] + } + let menu!: ReturnType + function Menu({ + value = '', + agent = 'claude', + editing = true, + structured = catalog + }: { + value?: string + agent?: string + editing?: boolean + structured?: MobileComposerStructuredCatalog + }) { + menu = useMobileNativeChatComposerMenu({ + value, + agent, + structuredCatalog: structured, + filePaths: [], + onChangeText: () => {}, + focusField: () => {}, + editing + }) + return null + } + let renderer!: ReturnType + await act(async () => { + renderer = create(createElement(Menu)) + }) + try { + expect(prepare).toHaveBeenCalledTimes(1) + for (const value of ['/', '/p', '/provider', '/provider-9']) { + act(() => renderer.update(createElement(Menu, { value }))) + act(() => menu.onSelectionChange({ start: value.length, end: value.length })) + } + expect(menu.rows.length).toBeGreaterThan(0) + expect(prepare).toHaveBeenCalledTimes(1) + act(() => renderer.update(createElement(Menu, { value: '/', editing: false }))) + expect(prepare).toHaveBeenCalledTimes(1) + act(() => + renderer.update( + createElement(Menu, { value: '/', structured: { ...catalog, reported: reported() } }) + ) + ) + expect(prepare).toHaveBeenCalledTimes(2) + act(() => renderer.update(createElement(Menu, { value: '/', agent: 'codex' }))) + expect(prepare).toHaveBeenCalledTimes(3) + } finally { + act(() => renderer.unmount()) + } + } +) diff --git a/mobile/src/session/mobile-queued-message-composer-context.ts b/mobile/src/session/mobile-queued-message-composer-context.ts new file mode 100644 index 000000000000..659442a19df2 --- /dev/null +++ b/mobile/src/session/mobile-queued-message-composer-context.ts @@ -0,0 +1,18 @@ +import type { NativeChatSkillDiscoverySnapshot } from '../../../src/shared/native-chat/input/native-chat-picker-items' +import { createContext } from 'react' +import type { MobileNativeChatStructuredComposer } from './native-chat/composer/MobileNativeChatComposer' + +export const MobileQueuedMessageComposerContext = createContext<{ + agent: string | null + structured: MobileNativeChatStructuredComposer | null + skillDiscovery?: NativeChatSkillDiscoverySnapshot + onNeedSkills?: () => void + filePaths: string[] + onNeedFiles: ((query: string) => void) | undefined + onError: (text: string) => void + onMicPress: (() => void) | undefined + micActive: boolean + dictationMode: string | undefined + onMicPressIn: () => void + onMicPressOut: () => void +} | null>(null) diff --git a/mobile/src/session/mobile-queued-message-edit-draft.ts b/mobile/src/session/mobile-queued-message-edit-draft.ts new file mode 100644 index 000000000000..3360ff120c47 --- /dev/null +++ b/mobile/src/session/mobile-queued-message-edit-draft.ts @@ -0,0 +1,238 @@ +import type { QueuedEditLeaseLoss } from '../../../src/shared/queued-message-edit-lease' +import type { MobileStructuredAgentMutate } from './use-mobile-structured-agent-mutation' +import type { MobileQueuedMessageCard } from './mobile-structured-queued-message-cards' +import { + mobileQueuedEditValueKey, + sameMobileQueuedEditImage +} from './mobile-queued-message-edit-value' +import { appendBufferedDictation } from '../terminal/terminal-live-dictation-routing' +import type { + MobileNativeChatDraftInputs, + MobileNativeChatDraftDestination +} from './native-chat/composer/mobile-native-chat-draft-inputs' +import type { AgentSessionQueuedMessage } from '../../../src/shared/agent-session-wire' +import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' +import type { RpcClient } from '../transport/rpc-client' +import type { MobileQueuedEditLease } from './mobile-queued-message-edit-lease' +import { pickedSkillsInText } from '../../../src/shared/structured-agent-session-send-mutation' +import { + queuedEditKeptImages, + queuedEditTextForKeptImages +} from '../../../src/shared/queued-message-edit' +import { appendReturnedDraftText } from '../../../src/shared/returned-draft-text' +import { + closeMobileNativeChatEditInputs, + readMobileNativeChatDraftInputs, + updateMobileNativeChatDraftInputs +} from './native-chat/composer/mobile-native-chat-draft-inputs' +import { setMobileNativeChatDraftText } from './native-chat/composer/mobile-native-chat-draft-store' +import { nativeChatTextWithFileReferences } from './native-chat/composer/mobile-native-chat-file-attachment' + +export type MobileQueuedMessageInlineEditor = { + messageId: string + scopeKey: string + client: RpcClient + sessionId: string + imagesCapable: boolean + text: string + saving: boolean + canSave: boolean + insertDictation?: (text: string) => boolean + change: (text: string) => void + save: () => Promise + removeImage: (id: string) => void + cancel: () => void +} + +export type MobileQueuedEditAttempt = { + phase: 'pending' | 'unknown' + expected: string + fingerprint: string + body: AgentSessionQueuedMessage['body'] + text: string + inputs: MobileNativeChatDraftInputs + key: string +} +export function mobileQueuedEditSaving(edit: MobileQueuedEditDraft): boolean { + return edit.attempt?.phase === 'pending' +} + +export type MobileQueuedEditDraft = { + scope: string + client: RpcClient + sessionId: string + published: { + cards: readonly MobileQueuedMessageCard[] + messages: readonly AgentSessionQueuedMessage[] | null + submissions: readonly AgentJournalSubmission[] + } + scopeKey: string + destination: string | null + handBackText: ((text: string) => boolean) | undefined + messageId: string + body: AgentSessionQueuedMessage['body'] + text: string + fingerprint: string + baseKey: string + kept: { + image: MobileNativeChatDraftInputs['images'][number] + block: ReturnType[number]['block'] + }[] + uncertain: boolean + leaseLoss?: QueuedEditLeaseLoss + attempt?: MobileQueuedEditAttempt + closed: boolean + pendingClose: boolean + lease: MobileQueuedEditLease +} + +export function closeMobileQueuedEditDraft( + edit: MobileQueuedEditDraft, + handBack: boolean, + notice?: (text: string) => void +): void { + if (edit.closed) { + return + } + edit.closed = true + edit.lease.end() + if (handBack) { + const inputs = readMobileNativeChatDraftInputs(edit.scopeKey) + const dirty = edit.uncertain || mobileQueuedEditValueKey(edit.text, inputs) !== edit.baseKey + const kept = edit.kept.filter((entry) => + inputs.images.some((image) => sameMobileQueuedEditImage(entry.image, image)) + ) + const text = dirty + ? queuedEditTextForKeptImages( + edit.text, + inputs.images.map(({ id }) => id), + inputs.images + .filter((image) => !kept.some((entry) => sameMobileQueuedEditImage(entry.image, image))) + .map(({ id }) => id) + ) + : '' + if (dirty && edit.destination) { + setMobileNativeChatDraftText(edit.destination, (before) => + appendReturnedDraftText(before, text) + ) + } else if (dirty) { + edit.handBackText?.(text) + } + closeMobileNativeChatEditInputs( + edit.scopeKey, + edit.destination ?? undefined, + kept.map(({ image }) => image), + !dirty + ) + if (dirty) { + notice?.('Your edit is in the chat box.') + } + } else { + closeMobileNativeChatEditInputs(edit.scopeKey) + } +} + +export function serializeMobileQueuedEdit( + edit: MobileQueuedEditDraft, + imagesCapable = false, + skillsCapable = false +) { + const inputs = readMobileNativeChatDraftInputs(edit.scopeKey) + return { + text: nativeChatTextWithFileReferences( + edit.text, + inputs.files.flatMap(({ path }) => (path ? [{ path }] : [])) + ), + ...(imagesCapable + ? { + images: inputs.images.map( + (image) => + edit.kept.find((entry) => sameMobileQueuedEditImage(entry.image, image))?.block ?? { + type: 'image-ref' as const, + path: image.path + } + ) + } + : {}), + ...(skillsCapable ? { skills: pickedSkillsInText(edit.text, inputs.skills, ['$', '/']) } : {}) + } +} + +export function removeMobileQueuedEditImage(edit: MobileQueuedEditDraft, id: string): void { + const images = readMobileNativeChatDraftInputs(edit.scopeKey).images + const kept = images.filter((image) => image.id !== id) + edit.text = queuedEditTextForKeptImages( + edit.text, + images.map(({ id }) => id), + kept.map(({ id }) => id) + ) + updateMobileNativeChatDraftInputs(edit.scopeKey, (current) => ({ + ...current, + images: kept + })) +} + +export function mobileQueuedEditSaveHeld( + edit: MobileQueuedEditDraft, + imagesCapable = false +): boolean { + const inputs = readMobileNativeChatDraftInputs(edit.scopeKey) + return ( + (inputs.pending ?? 0) > 0 || + inputs.files.some(({ path }) => path === null) || + (!imagesCapable && + JSON.stringify(inputs.images) !== JSON.stringify(edit.kept.map(({ image }) => image))) + ) +} + +export function seedMobileQueuedEditInputs( + scopeKey: string, + body: AgentSessionQueuedMessage['body'] +): void { + updateMobileNativeChatDraftInputs(scopeKey, () => ({ + skills: body.skills ?? [], + files: [], + images: queuedEditKeptImages(body).map(({ id, block }) => ({ + id, + path: block.path ?? block.url ?? '', + previewUri: block.url ?? block.path ?? '' + })) + })) +} + +export function insertMobileQueuedEditDictation( + edit: MobileQueuedEditDraft, + destination: MobileNativeChatDraftDestination, + text: string +): boolean { + if (!destination.active) { + return false + } + if (destination.scope !== edit.scopeKey) { + setMobileNativeChatDraftText(destination.scope, (before) => + appendBufferedDictation(before, text) + ) + } else { + edit.text = appendBufferedDictation(edit.text, text) + } + return true +} + +export type MobileStructuredQueuedEditArgs = { + sessionKey: string + sessionId: string | null + client: RpcClient | null + chatActive: boolean + enabled: boolean + imagesCapable?: boolean + skillsCapable?: boolean + composerScopeKey?: string | null + messages: readonly AgentSessionQueuedMessage[] | null + submissions?: readonly AgentJournalSubmission[] + cards: readonly MobileQueuedMessageCard[] + mutate: MobileStructuredAgentMutate + appendComposerText: ((text: string) => boolean) | undefined + onQueuedEditNotice?: (text: string) => void + onSendError: (text: string) => void + onActionResolved?: () => void +} diff --git a/mobile/src/session/mobile-queued-message-edit-lease.ts b/mobile/src/session/mobile-queued-message-edit-lease.ts index 7e1c40b59417..9b4c62c778a1 100644 --- a/mobile/src/session/mobile-queued-message-edit-lease.ts +++ b/mobile/src/session/mobile-queued-message-edit-lease.ts @@ -1,4 +1,7 @@ -import { startQueuedEditLease } from '../../../src/shared/queued-message-edit-lease' +import { + startQueuedEditLease, + type QueuedEditLeaseLoss +} from '../../../src/shared/queued-message-edit-lease' import type { RpcClient } from '../transport/rpc-client' import { callAgentSession } from './mobile-structured-agent-session-rpc' import { structuredSessionOperationId } from './structured-session-operation-id' @@ -10,12 +13,14 @@ export function startMobileQueuedEditLease(args: { sessionId: string messageId: string expectedBodyFingerprint: string + onLost: (loss: QueuedEditLeaseLoss) => void }) { return startQueuedEditLease({ sessionId: args.sessionId, messageId: args.messageId, editId: structuredSessionOperationId(), expectedBodyFingerprint: args.expectedBodyFingerprint, + onLost: args.onLost, request: (params) => { const client = args.getClient() return client diff --git a/mobile/src/session/mobile-queued-message-edit-open.ts b/mobile/src/session/mobile-queued-message-edit-open.ts new file mode 100644 index 000000000000..8a436be4ea86 --- /dev/null +++ b/mobile/src/session/mobile-queued-message-edit-open.ts @@ -0,0 +1,68 @@ +import type { QueuedEditLeaseLoss } from '../../../src/shared/queued-message-edit-lease' +import { + queuedMessageEditableText, + queuedEditKeptImages +} from '../../../src/shared/queued-message-edit' +import { agentSessionSendBodyFingerprint } from '../../../src/shared/structured-agent-session-send-mutation' +import { createUuidV4 } from '../../../src/shared/uuid-v4' +import { startMobileQueuedEditLease } from './mobile-queued-message-edit-lease' +import { mobileQueuedEditValueKey } from './mobile-queued-message-edit-value' +import { readMobileNativeChatDraftInputs } from './native-chat/composer/mobile-native-chat-draft-inputs' +import { + seedMobileQueuedEditInputs, + type MobileQueuedEditDraft as Edit, + type MobileStructuredQueuedEditArgs +} from './mobile-queued-message-edit-draft' + +export function createMobileQueuedEdit( + args: MobileStructuredQueuedEditArgs, + messageId: string, + getClient: () => MobileStructuredQueuedEditArgs['client'], + onLost: (edit: Edit, loss: QueuedEditLeaseLoss) => void +): Edit | null { + const { enabled, client, sessionId, sessionKey } = args + if (!enabled || !client || !sessionId) { + return null + } + const message = args.messages?.find((entry) => entry.messageId === messageId) + const text = message ? queuedMessageEditableText(message.body) : null + if (!message || text === null) { + return null + } + const fingerprint = agentSessionSendBodyFingerprint(sessionId, message.body) + const scopeKey = `queued-edit:${createUuidV4()}` + seedMobileQueuedEditInputs(scopeKey, message.body) + const edit: Edit = { + scope: sessionKey, + client, + sessionId, + published: { + messages: args.messages, + submissions: args.submissions ?? [], + cards: args.cards + }, + scopeKey, + destination: args.composerScopeKey ?? null, + handBackText: args.appendComposerText, + messageId, + body: message.body, + text, + fingerprint, + uncertain: false, + baseKey: mobileQueuedEditValueKey(text, readMobileNativeChatDraftInputs(scopeKey)), + kept: queuedEditKeptImages(message.body).map((entry, index) => ({ + block: entry.block, + image: readMobileNativeChatDraftInputs(scopeKey).images[index] + })), + closed: false, + pendingClose: false, + lease: startMobileQueuedEditLease({ + getClient, + sessionId, + messageId, + expectedBodyFingerprint: fingerprint, + onLost: (loss) => onLost(edit, loss) + }) + } + return edit +} diff --git a/mobile/src/session/mobile-queued-message-edit-outcome.ts b/mobile/src/session/mobile-queued-message-edit-outcome.ts new file mode 100644 index 000000000000..93dc2909e65b --- /dev/null +++ b/mobile/src/session/mobile-queued-message-edit-outcome.ts @@ -0,0 +1,109 @@ +import type { AgentSessionQueuedMessageUpdateResult } from '../../../src/shared/agent-session-wire' +import { agentSessionSendBodyFingerprint } from '../../../src/shared/structured-agent-session-send-mutation' +import type { MobileQueuedEditDraft } from './mobile-queued-message-edit-draft' +import type { StructuredAgentSessionMutationResult } from './mobile-structured-agent-session-rpc' +import { readMobileNativeChatDraftInputs } from './native-chat/composer/mobile-native-chat-draft-inputs' +import { mobileQueuedEditValueKey } from './mobile-queued-message-edit-value' + +type Outcome = 'close' | 'rebase' | 'gone' | 'changed' | 'refused' | 'stay' +export function settleMobileQueuedEdit( + edit: MobileQueuedEditDraft, + attempt = edit.attempt, + result?: StructuredAgentSessionMutationResult +): Outcome { + if (edit.closed || edit.attempt !== attempt) { + return 'stay' + } + const loss = + edit.leaseLoss?.lease === edit.lease && edit.leaseLoss.expected === edit.fingerprint + ? edit.leaseLoss.status + : undefined + const lost = loss === 'changed' ? 'changed' : loss ? 'gone' : undefined + const { messages, submissions } = edit.published + const message = messages?.find((entry) => entry.messageId === edit.messageId) + const available = edit.published.cards.some((entry) => entry.messageId === edit.messageId) + const sent = + attempt && + submissions.some( + (entry) => + entry.queuedMessageId === edit.messageId && entry.payloadFingerprint === attempt.fingerprint + ) + let proof = Boolean( + attempt && + ((message && + agentSessionSendBodyFingerprint(edit.sessionId, message.body) === attempt.fingerprint) || + sent) + ) + if (result?.status === 'accepted') { + 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 || (messages !== null && (!message || !available)) ? 'gone' : 'refused') + ) + } + proof = result.value.fingerprint === attempt?.fingerprint + if (!proof && attempt) { + attempt.phase = 'unknown' + edit.uncertain ||= attempt.fingerprint !== attempt.expected + } + } else if (result?.status === 'rejected') { + edit.attempt = undefined + return ( + lost ?? + (edit.pendingClose || (messages !== null && (!message || !available)) ? 'gone' : 'refused') + ) + } else if (result?.status === 'unknown' && attempt) { + attempt.phase = 'unknown' + edit.uncertain ||= attempt.fingerprint !== attempt.expected + } + if (attempt && proof) { + if (edit.uncertain && attempt.fingerprint === edit.fingerprint) { + attempt.phase = 'unknown' + return lost ?? (edit.pendingClose || sent ? 'gone' : 'stay') + } + const equal = + mobileQueuedEditValueKey(edit.text, readMobileNativeChatDraftInputs(edit.scopeKey)) === + attempt.key + const blocks = attempt.body.blocks.filter((block) => block.type === 'image-ref') + edit.kept = attempt.inputs.images.flatMap((image, index) => + blocks[index] ? [{ image, block: blocks[index] }] : [] + ) + edit.body = attempt.body + edit.baseKey = attempt.key + edit.fingerprint = attempt.fingerprint + edit.uncertain = false + edit.attempt = undefined + if (equal) { + return 'close' + } + return edit.pendingClose || + sent || + (messages !== null && (!message || !available)) || + loss === 'gone' || + loss === 'not-editable' + ? 'gone' + : loss === 'changed' && attempt.fingerprint === attempt.expected + ? 'changed' + : 'rebase' + } + if (lost) { + return lost + } + if (edit.pendingClose || (messages !== null && (!message || !available))) { + return 'gone' + } + if ( + !result && + message && + agentSessionSendBodyFingerprint(edit.sessionId, message.body) !== edit.fingerprint + ) { + return 'changed' + } + return 'stay' +} diff --git a/mobile/src/session/mobile-queued-message-edit-value.ts b/mobile/src/session/mobile-queued-message-edit-value.ts new file mode 100644 index 000000000000..be2abe1a6ebd --- /dev/null +++ b/mobile/src/session/mobile-queued-message-edit-value.ts @@ -0,0 +1,30 @@ +import type { MobileNativeChatDraftInputs } from './native-chat/composer/mobile-native-chat-draft-inputs' +import { pickedSkillsInText } from '../../../src/shared/structured-agent-session-send-mutation' + +type Image = MobileNativeChatDraftInputs['images'][number] +export function sameMobileQueuedEditImage(left: Image, right: Image): boolean { + return left.id === right.id && left.path === right.path && left.previewUri === right.previewUri +} +export function mobileQueuedEditValueKey( + text: string, + inputs: MobileNativeChatDraftInputs +): string { + return JSON.stringify([ + text, + pickedSkillsInText(text, inputs.skills, ['$', '/']), + inputs.images, + inputs.files, + inputs.pending ?? 0 + ]) +} + +export function snapshotMobileQueuedEditInputs( + inputs: MobileNativeChatDraftInputs +): MobileNativeChatDraftInputs { + return { + ...inputs, + images: inputs.images.map((image) => ({ ...image })), + files: inputs.files.map((file) => ({ ...file })), + skills: inputs.skills.map((skill) => ({ ...skill })) + } +} diff --git a/mobile/src/session/mobile-structured-agent-session-contract.ts b/mobile/src/session/mobile-structured-agent-session-contract.ts index 2be70f89bfac..f0d476ab2d4d 100644 --- a/mobile/src/session/mobile-structured-agent-session-contract.ts +++ b/mobile/src/session/mobile-structured-agent-session-contract.ts @@ -63,7 +63,9 @@ export type MobileStructuredAgentSessionArgs = { agent: string | null /** What the session reported for its `/` menu; undefined until read or on an older host. */ sessionCommands?: readonly AgentSessionSlashCommand[] + composerScopeKey?: string | null appendComposerText?: (text: string) => boolean + onQueuedEditNotice?: (message: string) => void onSendError: MobileNativeChatSendErrorReporter // Accepted queued actions retire the route’s failure banner. onActionResolved?: () => void diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.ts b/mobile/src/session/mobile-structured-agent-session-host-support.ts index 97e90b291e64..5af99dc6641e 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts @@ -20,7 +20,10 @@ import { AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY, AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-permission-modes-capability' -import { AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-runtime-capabilities' +import { + AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_MESSAGE_SKILLS_EDIT_RUNTIME_CAPABILITY +} from '../../../src/shared/agent-session-runtime-capabilities' import { SKILL_DISCOVER_WORKSPACE_CAPABILITY } from '../../../src/shared/skill-install-capability' export type StructuredAgentSessionHostSupport = { @@ -36,6 +39,7 @@ export type StructuredAgentSessionHostSupport = { queuedMessageReorder?: boolean // Older hosts edit queued text only. queuedMessageImageEdit?: boolean + queuedMessageSkillsEdit?: boolean // Hosts predating mobile access refuse this feed even if they implement it. statusFeed: boolean quietRepeatedStop: boolean @@ -63,6 +67,9 @@ export function structuredAgentSessionHostSupport( queuedMessages: capabilities.includes(AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY), queuedCommands: capabilities.includes(AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY), queuedMessageEdit: capabilities.includes(AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY), + queuedMessageSkillsEdit: capabilities.includes( + AGENT_SESSION_QUEUED_MESSAGE_SKILLS_EDIT_RUNTIME_CAPABILITY + ), queuedMessageReorder: capabilities.includes( AGENT_SESSION_QUEUED_MESSAGE_REORDER_RUNTIME_CAPABILITY ), diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index c6513a052076..a2b683c924d3 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -1,3 +1,4 @@ +import { stripSentImagePromptMarkers } from '../../../src/shared/native-chat-image-transcript-markers' // The shared projection decides each card's hold; this layer words it for the phone. import type { AgentSessionFailureFact } from '../../../src/shared/agent-session-failure' @@ -67,7 +68,10 @@ export function mobileQueuedMessageCards( ): MobileQueuedMessageCard[] { return projectQueuedMessageCards(queuedMessages, submissions, facts).map((card) => ({ ...card, - line: queuedMessageCardLine(card), + line: queuedMessageCardLine({ + ...card, + text: stripSentImagePromptMarkers(card.text, card.images?.length ?? 0) + }), needsAttention: queuedMessageCardNeedsAttention(card), caption: queuedMessageCardCaption(card, facts.agentName, facts.statedFailures ?? []), attribution: agentMessageAttribution('From', card.from) diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.structured.test.ts b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.structured.test.ts index d8a5604d9abb..76d501efbfbe 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.structured.test.ts +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.structured.test.ts @@ -1,7 +1,40 @@ +import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments' +import { + setMobileNativeChatDraftText, + readMobileNativeChatDraftEditGeneration +} from './mobile-native-chat-draft-store' +import { + readMobileNativeChatDraftInputs, + updateMobileNativeChatDraftInputs, + resetMobileNativeChatDraftInputsForTests +} from './mobile-native-chat-draft-inputs' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' +import { useMobileStructuredNativeChatSendBridge } from '../../use-mobile-structured-native-chat-send-bridge' +import { mobileNativeChatScopeKey } from '../mobile-native-chat-scope-key' +import { useMobileStructuredAgentSession } from '../../use-mobile-structured-agent-session' +import { + CAPABLE, + SESSION_ID, + snapshotEvent, + mutationOk, + ok +} from '../../use-mobile-structured-agent-session-queued.test-fixture' +import { structuredSendResultFixture } from '../../structured-agent-send-result.test-fixture' +import { findByHostType } from '../../../test-support/react-test-renderer-host-nodes' +import type { RpcClient } from '../../../transport/rpc-client' +vi.mock('../../../platform/media-picker', () => ({ + useMediaPicker: () => ({ pickImages: async () => [] }) +})) +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: async () => null, setItem: async () => {}, removeItem: async () => {} } +})) import { createElement, type ComponentProps } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' -import { MobileNativeChatComposer as NativeChatComposer } from './MobileNativeChatComposer' +import { + MobileNativeChatComposer as NativeChatComposer, + MobileNativeChatDraftComposer +} from './MobileNativeChatComposer' const getNoComposerEditGeneration = () => 0 @@ -99,6 +132,219 @@ describe('MobileNativeChatComposer on a structured chat', () => { ) const drawers = () => renderer!.root.findAll((node) => node.props.onHidden !== undefined) + it.each(['', 'See this'])( + 'sends an ordinary image with %j text through the real RPC and clears its visible chip', + async (text) => { + const scope = mobileNativeChatScopeKey('host', 'worktree', `image-rpc-${text}`)! + resetMobileNativeChatDraftInputsForTests() + setMobileNativeChatDraftText(scope, text) + updateMobileNativeChatDraftInputs(scope, (input) => ({ + ...input, + images: [{ id: 'photo', path: '/remote/photo.png', previewUri: 'file:///phone-photo.png' }] + })) + let publish: ((data: unknown) => void) | undefined + const sendRequest = vi.fn(async (method) => + method === 'agentSession.send' + ? mutationOk(structuredSendResultFixture('accepted')) + : ok({ models: [], current: {} }) + ) + const client: RpcClient = { + sendRequest, + subscribe: (_m, _p, listener) => { + publish = listener + return () => {} + }, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + function Conversation() { + const session = useMobileStructuredAgentSession({ + client, + sessionId: SESSION_ID, + sourceIdentity: 'host', + enabled: true, + connected: true, + agent: 'codex', + hostSupport: { ...CAPABLE, sendSkills: true }, + composerScopeKey: scope, + onSendError: vi.fn() + }) + const drafts = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId: `image-rpc-${text}`, + sessionId: SESSION_ID, + messages: [], + transcriptSettled: true + }) + const bridge = useMobileStructuredNativeChatSendBridge({ + ...drafts, + agent: 'codex', + sendStructured: session.sendWithOutcome, + onSendError: vi.fn() + }) + const image = useMobileNativeChatImageAttachments({ + client, + agent: 'codex', + activeHandleRef: { current: null }, + deviceTokenRef: { current: null }, + getActiveWorktreeConnectionId: async () => null, + connState: 'connected', + scopeKey: scope, + enabled: true, + structuredNativeChat: true, + showToast: vi.fn(), + onSendError: vi.fn(), + baseSend: bridge.sendWithOutcome, + readSeededLaunchDraft: () => null + }) + return draw({ + value: text, + onSend: image.sendNativeChat, + attachments: image.attachments, + onRemoveAttachment: image.removeAttachment, + structured: structured() + }) + } + await act(async () => { + renderer = create(createElement(Conversation)) + }) + act(() => publish?.(snapshotEvent())) + expect(labelled('Remove image')[0]).toBeDefined() + await act(async () => labelled('Send message')[0]!.props.onPress()) + const request = sendRequest.mock.calls.find(([method]) => method === 'agentSession.send')?.[1] + expect(request).toMatchObject({ + body: { + blocks: [ + ...(text ? [{ type: 'text', text }] : []), + { type: 'image-ref', path: '/remote/photo.png' } + ] + } + }) + expect(readMobileNativeChatDraftInputs(scope).images).toEqual([]) + expect(labelled('Remove image')).toEqual([]) + } + ) + + it.each([ + ['codex', '$review.v2'], + ['claude', '/review.v2'] + ] as const)( + '%s matches main capture/clear and real Send refs, and forgets a deleted pick', + async (agent, token) => { + const scope = mobileNativeChatScopeKey('host', 'worktree', 'menu')! + resetMobileNativeChatDraftInputsForTests() + setMobileNativeChatDraftText(scope, '/re') + let publish: ((data: unknown) => void) | undefined + const sendRequest = vi.fn(async (method) => + method === 'agentSession.send' + ? mutationOk(structuredSendResultFixture('accepted')) + : ok({ models: [], current: {} }) + ) + const client: RpcClient = { + sendRequest, + subscribe: (_m, _p, listener) => { + publish = listener + return () => {} + }, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + function Conversation() { + const session = useMobileStructuredAgentSession({ + client, + sessionId: SESSION_ID, + sourceIdentity: 'host', + enabled: true, + connected: true, + agent, + hostSupport: { ...CAPABLE, sendSkills: true }, + composerScopeKey: scope, + onSendError: vi.fn() + }) + const drafts = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId: 'menu', + sessionId: SESSION_ID, + messages: [], + transcriptSettled: true + }) + const bridge = useMobileStructuredNativeChatSendBridge({ + ...drafts, + agent, + sendStructured: session.sendWithOutcome, + onSendError: vi.fn() + }) + return createElement(MobileNativeChatDraftComposer, { + draftKey: scope, + agent, + draftScopeKey: scope, + onChangeText: drafts.setComposerText, + onSend: bridge.send, + sendSurfaceId: scope, + getSendCompletionGeneration: () => 0, + getComposerEditGeneration: () => readMobileNativeChatDraftEditGeneration(scope), + structured: structured({ + catalog: { + reported: [{ name: 'review.v2', kind: 'skill', path: '/remote/review/SKILL.md' }], + conversationCommands: [] + } + }) + }) + } + await act(async () => { + renderer = create(createElement(Conversation)) + }) + act(() => publish?.(snapshotEvent())) + act(() => field().props.onSelectionChange({ nativeEvent: { selection: { end: 3 } } })) + await act(async () => labelled(token)[0]!.props.onPress()) + expect(field().props.value).toBe(`${token} `) + await act(async () => labelled('Send message')[0]!.props.onPress()) + const request = sendRequest.mock.calls.find(([method]) => method === 'agentSession.send')?.[1] + const skills = [{ name: 'review.v2', path: '/remote/review/SKILL.md' }] + expect(request).toMatchObject({ body: { blocks: [{ type: 'text', text: token }] } }) + if (agent === 'codex') { + expect(request).toHaveProperty('body.skills', skills) + } else { + expect(request).not.toHaveProperty('body.skills') + } + expect(field().props.value).toBe('') + expect(readMobileNativeChatDraftInputs(scope).skills).toEqual(skills) + act(() => field().props.onChangeText(`${token} `)) + await act(async () => labelled('Send message')[0]!.props.onPress()) + const second = sendRequest.mock.calls.findLast( + ([method]) => method === 'agentSession.send' + )?.[1] + if (agent === 'codex') { + expect(second).toHaveProperty('body.skills', skills) + } else { + expect(second).not.toHaveProperty('body.skills') + } + // Pick again, delete, then retype: text alone cannot resurrect the old remote identity. + act(() => field().props.onChangeText('/re')) + act(() => field().props.onSelectionChange({ nativeEvent: { selection: { end: 3 } } })) + await act(async () => labelled(token)[0]!.props.onPress()) + act(() => field().props.onChangeText('')) + act(() => field().props.onChangeText(`${token} `)) + expect(readMobileNativeChatDraftInputs(scope).skills).toEqual([]) + await act(async () => labelled('Send message')[0]!.props.onPress()) + expect( + sendRequest.mock.calls.findLast(([method]) => method === 'agentSession.send')?.[1] + ).not.toHaveProperty('body.skills') + } + ) + it('puts a picked skill at the start of the draft, highlighted, and deletes it as one unit', async () => { const onChangeText = vi.fn() const catalog = structured() @@ -162,4 +408,29 @@ describe('MobileNativeChatComposer on a structured chat', () => { await act(async () => labelled('Send message')[0]!.props.onPress()) expect(onSend).toHaveBeenCalledExactlyOnceWith('') }) + it('forgets inline picked skill identity after its token is removed and retyped', async () => { + const scope = 'removed-skill' + updateMobileNativeChatDraftInputs(scope, () => ({ + images: [], + files: [], + skills: [{ name: 'review', path: '/remote/SKILL.md' }] + })) + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + draftScopeKey: scope, + value: '$review draft', + editSession: { cancel: vi.fn(), locked: false, refuseCommand: () => false }, + onChangeText: vi.fn(), + onSend: async () => false, + sendSurfaceId: scope, + getSendCompletionGeneration: getNoComposerEditGeneration + }) + ) + }) + const input = () => findByHostType(renderer!.root, 'TextInput') + await act(async () => input().props.onChangeText('draft')) + await act(async () => input().props.onChangeText('$review draft')) + expect(readMobileNativeChatDraftInputs(scope).skills).toEqual([]) + }) }) diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.test.ts b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.test.ts index 890aa689a01c..69fd85fa599e 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.test.ts +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.test.ts @@ -2,6 +2,10 @@ import { createElement, createRef, StrictMode, type ComponentProps, type ReactNo import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' import { Keyboard, type TextInput } from 'react-native' import { afterEach, describe, expect, it, vi } from 'vitest' +import { + useMobileNativeChatDraft, + setMobileNativeChatDraftText +} from './mobile-native-chat-draft-store' import { radii, spacing } from '../../../theme/mobile-theme' import { MobileNativeChatComposer as NativeChatComposer } from './MobileNativeChatComposer' import { @@ -9,6 +13,8 @@ import { findByHostType } from '../../../test-support/react-test-renderer-host-nodes' +import { captureMobileNativeChatTextDestination } from './mobile-native-chat-draft-inputs' + const getNoComposerEditGeneration = () => 0 function MobileNativeChatComposer({ @@ -109,6 +115,34 @@ describe('MobileNativeChatComposer', () => { ) } + it('uses ordinary append semantics through the registered focused dictation destination', async () => { + const scope = 'dictation-ordinary' + setMobileNativeChatDraftText(scope, '/compact') + function Registered() { + const value = useMobileNativeChatDraft(scope) + return createElement(MobileNativeChatComposer, { + value, + draftScopeKey: scope, + onChangeText: (text) => setMobileNativeChatDraftText(scope, text), + onSend: async () => true, + sendSurfaceId: scope, + getSendCompletionGeneration: () => 0 + }) + } + await act(async () => { + renderer = create(createElement(Registered)) + }) + const input = () => findByHostType(renderer!.root, 'TextInput') + act(() => input().props.onFocus()) + const insert = captureMobileNativeChatTextDestination()! + act(() => { + insert('hello') + insert('hello') + insert(' there ') + }) + expect(input().props.value).toBe('/compact hello hello there ') + }) + it('reports an accepted send without owning route-scoped draft cleanup', async () => { const onChangeText = vi.fn() const onSend = vi.fn().mockResolvedValue(true) diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx index 7bb30fd56a6a..8e5a728ee729 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx @@ -1,6 +1,18 @@ +import { getNativeChatAgentProfile } from '../../../../../src/shared/native-chat-agent-profiles' +import { + recordMobileNativeChatSkillPick, + setMobileNativeChatDraftText, + useMobileNativeChatDraft +} from './mobile-native-chat-draft-store' +import { appendBufferedDictation } from '../../../terminal/terminal-live-dictation-routing' +import { + focusMobileNativeChatTextDestination, + pruneMobileNativeChatDraftSkills, + registerMobileNativeChatTextDestination +} from './mobile-native-chat-draft-inputs' import type { NativeChatSkillDiscoverySnapshot } from '../../../../../src/shared/native-chat/input/native-chat-picker-items' import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import { ActivityIndicator, Keyboard, Pressable, type TextInput, View } from 'react-native' +import { ActivityIndicator, Keyboard, Pressable, type TextInput, Text, View } from 'react-native' import { ArrowUp, Mic, Plus, Square } from 'lucide-react-native' import { colors } from '../../../theme/mobile-theme' import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme' @@ -23,10 +35,6 @@ import { keepHeldPressThroughLongPress } from '../../held-press-long-press' import { mobileNativeChatComposerShowsStop } from './mobile-native-chat-composer-layout' import { MobileNativeChatStopButton } from '../input/MobileNativeChatStopButton' import { mobileNativeChatComposerStyles as styles } from './mobile-native-chat-composer-styles' -import { - recordMobileNativeChatSkillPick, - useMobileNativeChatDraft -} from './mobile-native-chat-draft-store' const NO_FILE_PATHS: string[] = [] const NO_ATTACHMENTS: PendingNativeChatImage[] = [] @@ -42,6 +50,10 @@ export type MobileNativeChatStructuredComposer = { } type Props = { + insertDictation?: (text: string) => boolean | void + draftScopeKey?: string | null + editSession?: { cancel: () => void; refuseCommand: (text: string) => boolean; locked: boolean } + onPickSkill?: (skill: { name: string; path: string }) => void inputRef?: React.RefObject // Terminal-backed chats keep curated commands and image-only attachment actions. structured?: MobileNativeChatStructuredComposer | null @@ -79,6 +91,10 @@ type Props = { export function MobileNativeChatComposer({ inputRef, + draftScopeKey, + insertDictation, + editSession, + onPickSkill, value, onChangeText, onSend, @@ -106,6 +122,19 @@ export function MobileNativeChatComposer({ onNeedSkills, stop = null }: Props): React.JSX.Element { + useEffect(() => { + if (!draftScopeKey) { + return + } + return registerMobileNativeChatTextDestination( + draftScopeKey, + insertDictation ?? + ((text) => + setMobileNativeChatDraftText(draftScopeKey, (before) => + appendBufferedDictation(before, text) + )) + ) + }, [draftScopeKey, insertDictation]) useMobileColorScheme() const fallbackInputRef = useRef(null) const fieldRef = inputRef ?? fallbackInputRef @@ -121,7 +150,26 @@ export function MobileNativeChatComposer({ const files = structured?.files ?? NO_FILES const menu = useMobileNativeChatComposerMenu({ value, - onChangeText, + onChangeText: (text) => { + if (editSession && draftScopeKey) { + pruneMobileNativeChatDraftSkills( + draftScopeKey, + text, + getNativeChatAgentProfile(agent)?.skillPrefix ?? '/' + ) + } + onChangeText(text) + }, + onPickSkill: (skill) => { + if (onPickSkill && skill.path) { + onPickSkill({ name: skill.token.slice(1), path: skill.path }) + } + if (!editSession) { + recordMobileNativeChatSkillPick(sendSurfaceId, skill) + } + }, + refuseCommand: editSession?.refuseCommand, + editing: editSession !== undefined, agent, structuredCatalog: structured?.catalog, filePaths, @@ -129,8 +177,6 @@ export function MobileNativeChatComposer({ skillDiscovery, onNeedSkills, onSlashMenuOpen: structured?.onSlashMenuOpen, - // The surface id is the draft key both send paths read picks back from. - onPickSkill: (skill) => recordMobileNativeChatSkillPick(sendSurfaceId, skill), focusField: () => fieldRef.current?.focus() }) const sendingRef = useRef(false) @@ -196,7 +242,9 @@ export function MobileNativeChatComposer({ menu.resetCursor() // Why: the turn is now the agent's — the keyboard would cover the reply. // A rejected send keeps it up so the handed-back draft stays editable. - Keyboard.dismiss() + if (!editSession) { + Keyboard.dismiss() + } } } finally { sendingRef.current = false @@ -211,22 +259,31 @@ export function MobileNativeChatComposer({ return ( {menu.rows.length > 0 ? ( - + ) : null} setFocused(true)} + onFocus={() => { + setFocused(true) + if (draftScopeKey) { + focusMobileNativeChatTextDestination(draftScopeKey) + } + }} onBlur={() => setFocused(false)} // Controlled only transiently right after an autocomplete insert. selection={menu.pendingSelection ?? undefined} @@ -235,6 +292,7 @@ export function MobileNativeChatComposer({ placeholderTextColor={colors.textMuted} selectionColor={colors.accentBlue} multiline + editable={!editSession?.locked} // Why: never revoke `editable` — iOS resigns first responder on a focused // field, so a transient lock would yank the keyboard mid-typing (#10681). // The lock gates sending; the draft survives and rides the next send. @@ -302,12 +360,22 @@ export function MobileNativeChatComposer({ )} ) : null} + {editSession ? ( + + Cancel + + ) : null} {showsStop && stop ? ( // Only this phone's own request holds Stop: a repeat is how a stuck stop escalates. ) : ( [ styles.sendButton, !canSend && styles.sendButtonDisabled, @@ -316,11 +384,15 @@ export function MobileNativeChatComposer({ onPress={handleSend} disabled={!canSend} > - + {editSession ? ( + Save + ) : ( + + )} )} diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposerAttachments.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposerAttachments.tsx index 877ee815afd0..8ae57b1b2a04 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposerAttachments.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposerAttachments.tsx @@ -1,3 +1,4 @@ +import { isRenderableImageUri } from '../visual/mobile-native-chat-image-preview' import { ActivityIndicator, Image, @@ -7,7 +8,7 @@ import { Text, View } from 'react-native' -import { FileText, X } from 'lucide-react-native' +import { FileText, Image as ImageIcon, X } from 'lucide-react-native' import { colors, radii, spacing } from '../../../theme/mobile-theme' import { themedStyleSheet } from '../../../theme/themed-styles' import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme' @@ -49,7 +50,21 @@ export function MobileNativeChatComposerAttachments({ > {images.map((image) => ( - + {isRenderableImageUri(image.previewUri) ? ( + + ) : ( + + + + )} {onRemoveImage ? ( onRemoveImage(image.id)} /> ) : null} diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenu.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenu.tsx index deec33b1275f..51fc68a73e76 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenu.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenu.tsx @@ -11,15 +11,21 @@ const HEADINGS = { command: 'Commands', skill: 'Skills', file: 'Files' } as cons export function MobileNativeChatComposerMenu({ rows, - onPick + onPick, + nestedScrollEnabled }: { rows: readonly ComposerMenuRow[] onPick: (row: ComposerMenuRow) => void + nestedScrollEnabled?: boolean }): React.JSX.Element { useMobileColorScheme() return ( - + {rows.map((row, index) => ( {rows[index - 1]?.kind !== row.kind ? ( diff --git a/mobile/src/session/native-chat/composer/mobile-attachment-operation-lifetime.test.ts b/mobile/src/session/native-chat/composer/mobile-attachment-operation-lifetime.test.ts new file mode 100644 index 000000000000..7c003b592de9 --- /dev/null +++ b/mobile/src/session/native-chat/composer/mobile-attachment-operation-lifetime.test.ts @@ -0,0 +1,215 @@ +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { afterEach, expect, it, vi } from 'vitest' +import { useMobileNativeChatFileAttachments } from './use-mobile-native-chat-file-attachments' +import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments' +import { + closeMobileNativeChatEditInputs, + readMobileNativeChatDraftInputs, + resetMobileNativeChatDraftInputsForTests +} from './mobile-native-chat-draft-inputs' +import type { RpcClient } from '../../../transport/rpc-client' +import type { RpcResponse } from '../../../transport/types' +const picker = vi.hoisted(() => ({ pickFiles: vi.fn(), pickImages: vi.fn() })) +vi.mock('../../../platform/media-picker', () => ({ useMediaPicker: () => picker })) +afterEach(() => resetMobileNativeChatDraftInputsForTests()) + +it.each(['files', 'images'] as const)( + 'retires remaining %s uploads on Cancel, and follows hand-back before the first chip', + async (kind) => { + for (const cancel of [true, false]) { + let release = () => {} + let started = () => {} + const firstStarted = new Promise((resolve) => { + started = resolve + }) + const held = new Promise((resolve) => { + release = resolve + }) + const starts: string[] = [] + const client: RpcClient = { + sendRequest: vi.fn(async (method: string): Promise => { + if (method.endsWith('uploadStart') || method === 'clipboard.startImageUpload') { + starts.push(method) + if (starts.length === 1) { + started() + await held + } + return { + id: 'r', + ok: true, + result: { uploadId: `upload-${starts.length}` }, + _meta: { runtimeId: 'r' } + } + } + const result = + method === 'clipboard.commitImageUpload' + ? `/stored/${starts.length}.png` + : method.endsWith('uploadCommit') + ? { + path: `/stored/${starts.length}.png`, + name: `${starts.length}.png`, + byteLength: 1 + } + : null + return { id: 'r', ok: true, result, _meta: { runtimeId: 'r' } } + }), + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + picker.pickFiles.mockImplementation(async function* () { + for (const name of ['a.txt', 'b.txt']) { + yield { name, mime: 'text/plain', byteLength: 1, base64: 'YQ==' } + } + }) + picker.pickImages.mockResolvedValue([ + { base64: 'YQ==', uri: 'file:///a.png' }, + { base64: 'Yg==', uri: 'file:///b.png' } + ]) + let attach = () => Promise.resolve() + let send = () => Promise.resolve(false) + const baseSend = vi.fn().mockResolvedValue('accepted') + function Probe() { + const file = useMobileNativeChatFileAttachments({ + client, + sessionId: 's', + scopeKey: 'edit', + enabled: true, + showToast: vi.fn() + }) + const image = useMobileNativeChatImageAttachments({ + client, + agent: 'codex', + activeHandleRef: { current: null }, + deviceTokenRef: { current: null }, + getActiveWorktreeConnectionId: async () => null, + connState: 'connected', + scopeKey: 'edit', + enabled: true, + structuredNativeChat: true, + showToast: vi.fn(), + onSendError: vi.fn(), + baseSend, + readSeededLaunchDraft: () => null + }) + attach = kind === 'files' ? file.attachFiles : () => image.attachImage('library') + return null + } + let edit!: ReturnType + await act(async () => { + edit = create(createElement(Probe)) + }) + let work!: Promise + await act(async () => { + work = attach() + await firstStarted + }) + act(() => closeMobileNativeChatEditInputs('edit', cancel ? undefined : 'bottom')) + if (!cancel) { + // Bottom Send must consume the transferred pending obligation even before a completed image exists. + function Bottom() { + const image = useMobileNativeChatImageAttachments({ + client, + agent: 'codex', + activeHandleRef: { current: null }, + deviceTokenRef: { current: null }, + getActiveWorktreeConnectionId: async () => null, + connState: 'connected', + scopeKey: 'bottom', + enabled: true, + structuredNativeChat: true, + showToast: vi.fn(), + onSendError: vi.fn(), + baseSend, + readSeededLaunchDraft: () => null + }) + send = () => image.sendNativeChat('bottom') + return null + } + let bottom!: ReturnType + await act(async () => { + bottom = create(createElement(Bottom)) + }) + await act(async () => expect(await send()).toBe(false)) + expect(baseSend).not.toHaveBeenCalled() + act(() => bottom.unmount()) + } + await act(async () => { + release() + await work + }) + expect(starts).toHaveLength(cancel ? 1 : 2) + const result = readMobileNativeChatDraftInputs('bottom') + expect(result[kind]).toHaveLength(cancel ? 0 : 2) + expect(result.pending ?? 0).toBe(0) + act(() => edit.unmount()) + resetMobileNativeChatDraftInputsForTests() + } + } +) + +it('retains unique image identity across a real hook remount and removes only the chosen photo', async () => { + const client: RpcClient = { + sendRequest: vi.fn(async (method: string): Promise => ({ + id: 'r', + ok: true, + result: + method === 'clipboard.startImageUpload' + ? { uploadId: 'upload' } + : method === 'clipboard.commitImageUpload' + ? '/stored/photo.png' + : null, + _meta: { runtimeId: 'r' } + })), + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + picker.pickImages.mockResolvedValue([{ base64: 'YQ==', uri: 'file:///photo.png' }]) + let hook!: ReturnType + function Probe() { + hook = useMobileNativeChatImageAttachments({ + client, + agent: 'codex', + activeHandleRef: { current: null }, + deviceTokenRef: { current: null }, + getActiveWorktreeConnectionId: async () => null, + connState: 'connected', + scopeKey: 'retained', + enabled: true, + structuredNativeChat: true, + showToast: vi.fn(), + onSendError: vi.fn(), + baseSend: vi.fn().mockResolvedValue('accepted'), + readSeededLaunchDraft: () => null + }) + return null + } + let mounted!: ReturnType + await act(async () => { + mounted = create(createElement(Probe)) + }) + await act(async () => hook.attachImage('library')) + const first = hook.attachments[0]!.id + act(() => mounted.unmount()) + await act(async () => { + mounted = create(createElement(Probe)) + }) + await act(async () => hook.attachImage('library')) + const second = hook.attachments[1]!.id + expect(second).not.toBe(first) + act(() => hook.removeAttachment(second)) + expect(hook.attachments.map(({ id }) => id)).toEqual([first]) + act(() => mounted.unmount()) +}) diff --git a/mobile/src/session/native-chat/composer/mobile-draft-inputs-scaling.test.ts b/mobile/src/session/native-chat/composer/mobile-draft-inputs-scaling.test.ts new file mode 100644 index 000000000000..891f40ad215e --- /dev/null +++ b/mobile/src/session/native-chat/composer/mobile-draft-inputs-scaling.test.ts @@ -0,0 +1,166 @@ +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { afterEach, expect, it, vi } from 'vitest' +import { useMobileNativeChatFileAttachments } from './use-mobile-native-chat-file-attachments' +import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments' +import { + useMobileNativeChatDraft, + setMobileNativeChatDraftText, + resetMobileNativeChatDraftStoreForTests +} from './mobile-native-chat-draft-store' +import { + subscribeMobileNativeChatDraftInputs, + updateMobileNativeChatDraftInputs, + closeMobileNativeChatEditInputs, + useMobileNativeChatDraftInputs +} from './mobile-native-chat-draft-inputs' + +const picker = vi.hoisted(() => ({ pickFiles: vi.fn(), pickImages: vi.fn() })) +vi.mock('../../../platform/media-picker', () => ({ useMediaPicker: () => picker })) +afterEach(resetMobileNativeChatDraftStoreForTests) + +it.each([20, 200])( + 'isolates mounted text, input, image and file consumers across %i other scopes', + async (count) => { + const scopes = [ + 'own-host:target', + ...Array.from({ length: count }, (_, i) => `other-host:${i}`) + ] + const own = scopes[0] + const renders = scopes.map(() => ({ text: 0, input: 0, image: 0, file: 0 })) + const values: { + text: string + pending: number + images: readonly string[] + files: readonly string[] + }[] = scopes.map(() => ({ text: '', pending: 0, images: [], files: [] })) + const upstream = scopes.map(() => vi.fn()) + const releases = scopes.map((scope, i) => + subscribeMobileNativeChatDraftInputs(scope, upstream[i]) + ) + function Text({ index }: { index: number }) { + values[index].text = useMobileNativeChatDraft(scopes[index]) + renders[index].text++ + return null + } + function Input({ index }: { index: number }) { + values[index].pending = useMobileNativeChatDraftInputs(scopes[index]).pending ?? 0 + renders[index].input++ + return null + } + function File({ index }: { index: number }) { + const file = useMobileNativeChatFileAttachments({ + client: null, + sessionId: null, + scopeKey: scopes[index], + enabled: false, + showToast: vi.fn() + }) + values[index].files = file.files.map((entry) => entry.path ?? '') + renders[index].file++ + return null + } + function Image({ index }: { index: number }) { + const image = useMobileNativeChatImageAttachments({ + client: null, + agent: 'codex', + activeHandleRef: { current: null }, + deviceTokenRef: { current: null }, + getActiveWorktreeConnectionId: async () => null, + connState: 'disconnected', + scopeKey: scopes[index], + enabled: false, + structuredNativeChat: true, + showToast: vi.fn(), + onSendError: vi.fn(), + baseSend: async () => 'rejected', + readSeededLaunchDraft: () => null + }) + values[index].images = image.attachments.map((entry) => entry.path) + renders[index].image++ + return null + } + let view: ReturnType | undefined + await act(async () => { + view = create( + createElement( + 'fixture', + {}, + scopes.flatMap((_, index) => [ + createElement(Text, { key: `t${index}`, index }), + createElement(Input, { key: `p${index}`, index }), + createElement(File, { key: `f${index}`, index }), + createElement(Image, { key: `i${index}`, index }) + ]) + ) + ) + }) + renders.forEach((entry) => { + entry.text = entry.input = entry.image = entry.file = 0 + }) + try { + await act(async () => setMobileNativeChatDraftText(own, 'own text')) + expect(values[0].text).toBe('own text') + expect(renders[0]).toEqual({ text: 1, input: 0, image: 0, file: 0 }) + await act(async () => + updateMobileNativeChatDraftInputs(own, (current) => ({ ...current, pending: 1 })) + ) + expect(values[0].pending).toBe(1) + await act(async () => + updateMobileNativeChatDraftInputs(own, (current) => ({ + ...current, + images: [{ id: 'image', path: '/saved.png', previewUri: 'file:///saved.png' }] + })) + ) + expect(values[0].images).toEqual(['/saved.png']) + await act(async () => + updateMobileNativeChatDraftInputs(own, (current) => ({ + ...current, + files: [ + { id: 'file', path: '/saved.txt', name: 'saved.txt', mime: 'text/plain', byteLength: 1 } + ] + })) + ) + expect(values[0].files).toEqual(['/saved.txt']) + expect(renders[0]).toEqual({ text: 1, input: 3, image: 3, file: 3 }) + expect(upstream[0]).toHaveBeenCalledTimes(3) + for (let index = 1; index < scopes.length; index++) { + expect(renders[index]).toEqual({ text: 0, input: 0, image: 0, file: 0 }) + expect(upstream[index]).not.toHaveBeenCalled() + } + } finally { + await act(async () => view?.unmount()) + releases.forEach((release) => release()) + } + await act(async () => + updateMobileNativeChatDraftInputs(own, (current) => ({ ...current, pending: 0 })) + ) + expect(renders[0]).toEqual({ text: 1, input: 3, image: 3, file: 3 }) + expect(upstream[0]).toHaveBeenCalledTimes(3) + } +) +it.each([20, 200])( + 'notifies only affected scopes across %i retained input subscribers and retires listeners', + (count) => { + const idle = vi.fn() + const releases = Array.from({ length: count }, (_, index) => + subscribeMobileNativeChatDraftInputs(`idle-${index}`, idle) + ) + const source = vi.fn() + const destination = vi.fn() + releases.push( + subscribeMobileNativeChatDraftInputs('queued-edit:one', source), + subscribeMobileNativeChatDraftInputs('bottom', destination) + ) + updateMobileNativeChatDraftInputs('queued-edit:one', (inputs) => ({ ...inputs, pending: 1 })) + expect(source).toHaveBeenCalledTimes(1) + expect(idle).not.toHaveBeenCalled() + closeMobileNativeChatEditInputs('queued-edit:one', 'bottom') + expect(source).toHaveBeenCalledTimes(2) + expect(destination).toHaveBeenCalledTimes(1) + expect(idle).not.toHaveBeenCalled() + releases.forEach((release) => release()) + updateMobileNativeChatDraftInputs('bottom', (inputs) => ({ ...inputs, pending: 0 })) + expect(destination).toHaveBeenCalledTimes(1) + } +) diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.test.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.test.ts index ec7d3e510be4..83cfd0f288f9 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.test.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.test.ts @@ -1,3 +1,4 @@ +import { prepareQueuedMessageEditText } from '../../../../../src/shared/queued-message-edit-command' import { describe, expect, it } from 'vitest' import { deriveComposerAutocomplete } from '../../../../../src/shared/native-chat-composer-autocomplete' import type { NativeChatSkillDiscoverySnapshot } from '../../../../../src/shared/native-chat/input/native-chat-picker-items' @@ -13,6 +14,7 @@ import { function mobileComposerSlashItems(input: { agent: string + editing?: boolean structured: MobileComposerStructuredCatalog | undefined query: string discovery?: NativeChatSkillDiscoverySnapshot @@ -27,7 +29,10 @@ function mobileComposerSlashItems(input: { catalog.profile, catalog.discovery, null, - catalog.sessionSkills + catalog.sessionSkills, + input.editing + ? (name) => prepareQueuedMessageEditText(input.agent, input.structured?.reported)(`/${name}`) + : undefined ) return result.mode === 'slash' ? boundedMenuItems(result.items) : [] } @@ -176,3 +181,27 @@ describe('narrowedMenuItems', () => { expect(narrowedMenuItems(commandsOnly, 'skill')).toEqual(commandsOnly) }) }) + +it('keeps edit-valid provider commands and namesake skills without changing the ordinary catalog', () => { + const input = { + agent: 'claude', + structured: { + reported: [ + { name: 'model', kind: 'skill' as const, path: '/model/SKILL.md' }, + { name: 'review', kind: 'command' as const }, + { name: 'goal', kind: 'command' as const } + ], + conversationCommands: ['clear' as const, 'compact' as const] + }, + query: '' + } + const ordinary = mobileComposerSlashItems(input) + const editing = mobileComposerSlashItems({ ...input, editing: true }) + expect(editing.map(({ kind, name }) => `${kind}:${name}`)).toEqual([ + 'command:review', + 'command:goal', + 'skill:model' + ]) + expect(ordinary.map(({ name }) => name)).toContain('compact') + expect(ordinary.map(({ kind, name }) => `${kind}:${name}`)).toContain('command:model') +}) diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-draft-inputs.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-draft-inputs.ts new file mode 100644 index 000000000000..34d8c797ee38 --- /dev/null +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-draft-inputs.ts @@ -0,0 +1,143 @@ +import { useCallback, useSyncExternalStore } from 'react' +import { pickedSkillsInText } from '../../../../../src/shared/structured-agent-session-send-mutation' +import type { AgentJournalSkillRef } from '../../../../../src/shared/agent-session-journal-types' +import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' +import type { PendingNativeChatFile } from './mobile-native-chat-file-attachment' + +export type MobileNativeChatDraftInputs = { + images: readonly PendingNativeChatImage[] + files: readonly PendingNativeChatFile[] + skills: readonly AgentJournalSkillRef[] + pending?: number +} +const empty: MobileNativeChatDraftInputs = { images: [], files: [], skills: [] } +const drafts = new Map() +const listeners = new Map void>>() +// Operations capture this object: hand-back redirects it; Cancel removes its right to publish. +export type MobileNativeChatDraftDestination = { scope: string; active: boolean } +const destinations = new Map() +export function mobileNativeChatDraftDestination(scope: string): MobileNativeChatDraftDestination { + let destination = destinations.get(scope) + if (!destination) { + destination = { scope, active: true } + destinations.set(scope, destination) + } + return destination +} +export function readMobileNativeChatDraftInputs(scope: string | null): MobileNativeChatDraftInputs { + return scope ? (drafts.get(scope) ?? empty) : empty +} +export function updateMobileNativeChatDraftInputs( + target: string | MobileNativeChatDraftDestination, + update: (current: MobileNativeChatDraftInputs) => MobileNativeChatDraftInputs +): void { + const destination = typeof target === 'string' ? mobileNativeChatDraftDestination(target) : target + if (!destination.active) { + return + } + drafts.set(destination.scope, update(readMobileNativeChatDraftInputs(destination.scope))) + listeners.get(destination.scope)?.forEach((listener) => listener()) +} +export function subscribeMobileNativeChatDraftInputs( + scope: string | null, + listener: () => void +): () => void { + if (!scope) { + return () => {} + } + const scoped = listeners.get(scope) ?? new Set<() => void>() + listeners.set(scope, scoped) + scoped.add(listener) + return () => { + scoped.delete(listener) + if (scoped.size === 0) { + listeners.delete(scope) + } + } +} +export function useMobileNativeChatDraftInputs(scope: string | null): MobileNativeChatDraftInputs { + const subscribe = useCallback( + (listener: () => void) => subscribeMobileNativeChatDraftInputs(scope, listener), + [scope] + ) + return useSyncExternalStore(subscribe, () => readMobileNativeChatDraftInputs(scope)) +} +export function closeMobileNativeChatEditInputs( + scope: string, + to?: string, + kept: readonly PendingNativeChatImage[] = [], + clean = false +): void { + const destination = mobileNativeChatDraftDestination(scope) + const input = readMobileNativeChatDraftInputs(scope) + if (to) { + updateMobileNativeChatDraftInputs(to, (current) => ({ + images: [ + ...current.images, + ...input.images.filter( + (image) => + !kept.some( + (owned) => + owned.id === image.id && + owned.path === image.path && + owned.previewUri === image.previewUri + ) + ) + ], + files: [...current.files, ...(clean ? [] : input.files)], + skills: [...current.skills, ...(clean ? [] : input.skills)], + pending: (current.pending ?? 0) + (input.pending ?? 0) + })) + destination.scope = to + } else { + destination.active = false + } + drafts.delete(scope) + destinations.delete(scope) + listeners.get(scope)?.forEach((listener) => listener()) +} + +const textDestinations = new Map boolean | void>() +let focusedScope: string | null = null +export function registerMobileNativeChatTextDestination( + scope: string, + insert: (text: string) => boolean | void +): () => void { + textDestinations.set(scope, insert) + return () => { + if (textDestinations.get(scope) === insert) { + textDestinations.delete(scope) + } + } +} +export function focusMobileNativeChatTextDestination(scope: string): void { + focusedScope = scope +} +export function captureMobileNativeChatTextDestination(): + | ((text: string) => boolean | void) + | undefined { + return focusedScope ? textDestinations.get(focusedScope) : undefined +} + +export function resetMobileNativeChatDraftInputsForTests(): void { + drafts.clear() + destinations.clear() + textDestinations.clear() + focusedScope = null +} + +export function pruneMobileNativeChatDraftSkills( + scope: string, + text: string, + sigil: '$' | '/' | readonly ('$' | '/')[] = '$' +): void { + const current = readMobileNativeChatDraftInputs(scope) + const skills = pickedSkillsInText( + text, + current.skills, + typeof sigil === 'string' ? [sigil] : sigil + ) + if (skills.length !== current.skills.length) { + updateMobileNativeChatDraftInputs(scope, (current) => ({ ...current, skills })) + } +} diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.test.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.test.ts index d709849a2456..658c7742ffbf 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.test.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.test.ts @@ -4,6 +4,8 @@ import { afterEach, describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../../../../src/shared/native-chat-types' import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' import { + clearMobileNativeChatDraftForSend, + readMobileNativeChatDraftEditGeneration, readMobileNativeChatPickedSkills, editMobileNativeChatDraft, readMobileNativeChatSkillOrigin, @@ -13,6 +15,11 @@ import { setMobileNativeChatDraftText, useMobileNativeChatDraft } from './mobile-native-chat-draft-store' +import { + readMobileNativeChatDraftInputs, + updateMobileNativeChatDraftInputs, + resetMobileNativeChatDraftInputsForTests +} from './mobile-native-chat-draft-inputs' type DraftState = ReturnType & { composerText: string } @@ -23,6 +30,7 @@ describe('mobile native-chat draft store', () => { afterEach(() => { act(() => renderer?.unmount()) resetMobileNativeChatDraftStoreForTests() + resetMobileNativeChatDraftInputsForTests() renderer = null state = null }) @@ -77,6 +85,25 @@ describe('mobile native-chat draft store', () => { expect(currentState().composerText).toBe('ping') }) + it('keeps main picked refs across Send-clear but revokes them on a real token edit', () => { + const scope = 'sent-skills' + setMobileNativeChatDraftText(scope, '$review draft') + updateMobileNativeChatDraftInputs(scope, () => ({ + images: [], + files: [], + skills: [{ name: 'review', path: '/remote/SKILL.md' }] + })) + const generation = readMobileNativeChatDraftEditGeneration(scope) + clearMobileNativeChatDraftForSend(scope, generation - 1, '$review draft') + expect(readMobileNativeChatDraftInputs(scope).skills).toHaveLength(1) + clearMobileNativeChatDraftForSend(scope, generation, '$review draft') + expect(readMobileNativeChatPickedSkills(scope)).toEqual([ + { name: 'review', path: '/remote/SKILL.md' } + ]) + editMobileNativeChatDraft(scope, 'different text') + expect(readMobileNativeChatPickedSkills(scope)).toEqual([]) + }) + it('keeps a typed draft after the chat screen unmounts and remounts', async () => { await mount('a') act(() => currentState().setComposerText('half-written prompt')) diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.ts index 7ae8a24941f5..bd5116d981b3 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.ts @@ -1,6 +1,11 @@ import { useSyncExternalStore, type SetStateAction } from 'react' import type { AgentJournalSkillRef } from '../../../../../src/shared/agent-session-journal-types' -import { pickedSkillsInText } from '../../../../../src/shared/structured-agent-session-send-mutation' +import { + resetMobileNativeChatDraftInputsForTests, + pruneMobileNativeChatDraftSkills, + readMobileNativeChatDraftInputs, + updateMobileNativeChatDraftInputs +} from './mobile-native-chat-draft-inputs' import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' // Text and its edit revision outlive the screen, including callbacks from an older mount. @@ -8,7 +13,6 @@ import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-l type Draft = { text: string editGeneration: number - skills?: AgentJournalSkillRef[] skillOrigin?: string } @@ -29,9 +33,11 @@ export function recordMobileNativeChatSkillPick( const current = drafts.get(draftKey) ?? { text: '', editGeneration: 0 } const name = skill.token.slice(1) // A pick replaces whatever file its token named before, like re-picking a desktop chip. - const kept = (current.skills ?? []).filter((held) => held.name !== name) - const skills = skill.path ? [...kept, { name, path: skill.path }] : kept - drafts.set(draftKey, { ...current, skillOrigin: skill.token, skills }) + updateMobileNativeChatDraftInputs(draftKey, (inputs) => { + const kept = inputs.skills.filter((held) => held.name !== name) + return { ...inputs, skills: skill.path ? [...kept, { name, path: skill.path }] : kept } + }) + drafts.set(draftKey, { ...current, skillOrigin: skill.token }) } // A rejected send's restored text gets back the files it named, even if newer edits dropped them. @@ -39,15 +45,14 @@ export function restoreMobileNativeChatPickedSkills( draftKey: string, skills: readonly AgentJournalSkillRef[] ): void { - const current = drafts.get(draftKey) - if (!current || skills.length === 0) { + if (!drafts.has(draftKey) || skills.length === 0) { return } - const paths = new Set((current.skills ?? []).map((skill) => skill.path)) - drafts.set(draftKey, { + const paths = new Set(readMobileNativeChatDraftInputs(draftKey).skills.map((skill) => skill.path)) + updateMobileNativeChatDraftInputs(draftKey, (current) => ({ ...current, - skills: [...(current.skills ?? []), ...skills.filter((skill) => !paths.has(skill.path))] - }) + skills: [...current.skills, ...skills.filter((skill) => !paths.has(skill.path))] + })) } export function readMobileNativeChatSkillOrigin(draftKey: string): string | null { @@ -55,7 +60,7 @@ export function readMobileNativeChatSkillOrigin(draftKey: string): string | null } export function readMobileNativeChatPickedSkills(draftKey: string): AgentJournalSkillRef[] { - return drafts.get(draftKey)?.skills ?? [] + return [...readMobileNativeChatDraftInputs(draftKey).skills] } function subscribe(listener: () => void): () => void { @@ -73,14 +78,14 @@ function writeDraft(draftKey: string, update: SetStateAction, userEdit: return } // Send clears and restores are not edits, so a rejected draft keeps its picks. - const skills = - userEdit && current?.skills ? pickedSkillsInText(next, current.skills) : current?.skills + if (userEdit) { + pruneMobileNativeChatDraftSkills(draftKey, next) + } const skillOrigin = userEdit && current?.skillOrigin !== next.split(/\s/, 1)[0] ? undefined : current?.skillOrigin drafts.set(draftKey, { text: next, editGeneration: (current?.editGeneration ?? 0) + 1, - ...(skills ? { skills } : {}), ...(skillOrigin ? { skillOrigin } : {}) }) if (next === text) { @@ -123,6 +128,7 @@ export function useMobileNativeChatDraft(draftKey: string | null): string { } export function resetMobileNativeChatDraftStoreForTests(): void { + resetMobileNativeChatDraftInputsForTests() drafts.clear() mobileNativeChatLaunchDraftSeeds.clear() } diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-file-attachment.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-file-attachment.ts index d9f39a2159b2..cfc87596d808 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-file-attachment.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-file-attachment.ts @@ -33,6 +33,7 @@ function nativeChatFileAttachFailureMessage(error: unknown, fileName?: string): // Keep successfully uploaded files when a later pick/upload fails; only the failing chip is removed. export async function uploadPickedNativeChatFiles(args: { + canContinue?: () => boolean picked: AsyncIterable upload: (file: PickedMobileFile) => Promise nextId: () => string @@ -42,7 +43,13 @@ export async function uploadPickedNativeChatFiles(args: { onError: (message: string) => void }): Promise { try { + if (args.canContinue?.() === false) { + return + } for await (const file of args.picked) { + if (args.canContinue?.() === false) { + break + } const id = args.nextId() args.onPicked({ id, @@ -57,6 +64,9 @@ export async function uploadPickedNativeChatFiles(args: { args.onFailed(id) args.onError(nativeChatFileAttachFailureMessage(error, file.name)) } + if (args.canContinue?.() === false) { + break + } } } catch (error) { args.onError(nativeChatFileAttachFailureMessage(error)) diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-image-attachment.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-image-attachment.ts index bfd738b84b65..9e721634e7e0 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-image-attachment.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-image-attachment.ts @@ -25,6 +25,7 @@ export function appendPendingNativeChatImages( } export type UploadNativeChatImagesDeps = { + readonly canContinue?: () => boolean readonly client: MobileClipboardImageRpcSender readonly getConnectionId: () => Promise readonly pickImages: ( @@ -43,6 +44,7 @@ export async function uploadMobileNativeChatImages( source: MobileImageSource, { client, + canContinue, getConnectionId, pickImages, onUploadStart, @@ -52,11 +54,20 @@ export async function uploadMobileNativeChatImages( const picked = await pickImages(source) const uploaded: Omit[] = [] let connectionId: string | null = null + if (canContinue?.() === false) { + return uploaded + } for await (const image of picked) { + if (canContinue?.() === false) { + break + } if (uploaded.length === 0) { onUploadStart?.() connectionId = await getConnectionId() } + if (canContinue?.() === false) { + break + } const path = await saveMobileClipboardImageAsTempFile(client, image.base64, { connectionId }) const previewUri = image.uri ?? `data:image/png;base64,${image.base64}` const result = { @@ -65,6 +76,9 @@ export async function uploadMobileNativeChatImages( } uploaded.push(result) onImageUploaded?.(result) + if (canContinue?.() === false) { + break + } } return uploaded } diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-menu.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-menu.ts index 2bb0b3e1539b..e6a5c4e96bc3 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-menu.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-menu.ts @@ -1,3 +1,4 @@ +import { prepareQueuedMessageEditText } from '../../../../../src/shared/queued-message-edit-command' import { useEffect, useMemo, useState } from 'react' import { applyMentionSuggestion, @@ -37,6 +38,8 @@ export function useMobileNativeChatComposerMenu(input: { onSlashMenuOpen?: () => void /** A picked skill; a send carries its file while its token stays, and treats a leading one as chat. */ onPickSkill?: (skill: { token: string; path?: string }) => void + editing?: boolean + refuseCommand?: (text: string) => boolean focusField: () => void }) { const { value, onChangeText, agent, structuredCatalog, filePaths, skillDiscovery } = input @@ -46,6 +49,15 @@ export function useMobileNativeChatComposerMenu(input: { const [pendingSelection, setPendingSelection] = useState(null) const [menuKind, setMenuKind] = useState(null) + const reported = structuredCatalog?.reported + const acceptsText = useMemo( + () => prepareQueuedMessageEditText(agent ?? 'codex', reported), + [agent, reported] + ) + const commandAvailable = useMemo( + () => (input.editing ? (name: string) => acceptsText(`/${name}`) : undefined), + [input.editing, acceptsText] + ) const catalog = useMemo( () => mobileComposerCatalog({ agent, structured: structuredCatalog, discovery: skillDiscovery }), @@ -79,9 +91,10 @@ export function useMobileNativeChatComposerMenu(input: { catalog.profile, catalog.discovery, null, - catalog.sessionSkills + catalog.sessionSkills, + commandAvailable ), - [beforeCaret, catalog] + [beforeCaret, catalog, commandAvailable] ) const rows = useMemo(() => { if (autocomplete.mode === 'mention') { @@ -127,6 +140,9 @@ export function useMobileNativeChatComposerMenu(input: { if (autocomplete.mode === 'none') { return } + if (row.kind === 'command' && input.refuseCommand?.(row.token)) { + return + } setMenuKind(null) // Restore page field focus after a menu tap so typing continues at the inserted pick. input.focusField() diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-tray.test.tsx b/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-tray.test.tsx index 4919ccd0fd24..e8cac10515f2 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-tray.test.tsx +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-tray.test.tsx @@ -208,3 +208,7 @@ describe('useMobileNativeChatComposerTray', () => { expect(duplicateKeys).toEqual([]) }) }) + +vi.mock('../../MobileQueuedMessageComposer', () => ({ + MobileQueuedMessageComposer: 'QueuedMessageComposer' +})) diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-attachments.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-attachments.ts index bbefbcf0056d..82563943e4ad 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-attachments.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-attachments.ts @@ -1,17 +1,20 @@ -import { useCallback, useRef, useState } from 'react' +import { createUuidV4 } from '../../../../../src/shared/uuid-v4' +import { + useMobileNativeChatDraftInputs, + updateMobileNativeChatDraftInputs, + mobileNativeChatDraftDestination, + type MobileNativeChatDraftDestination +} from './mobile-native-chat-draft-inputs' +import { useCallback } from 'react' import type { RpcClient } from '../../../transport/rpc-client' import { useMediaPicker } from '../../../platform/media-picker' import { uploadMobileAgentSessionAttachment } from '../../mobile-agent-session-attachment-upload' -import { withScopeAttachments } from './mobile-native-chat-image-scope-state' import { uploadPickedNativeChatFiles, type PendingNativeChatFile } from './mobile-native-chat-file-attachment' type ShowToast = (message: string, durationMs?: number) => void -type FilesByScope = Readonly> - -const NO_FILES: readonly PendingNativeChatFile[] = [] export type MobileNativeChatFileAttachments = { readonly files: readonly PendingNativeChatFile[] @@ -28,17 +31,19 @@ export function useMobileNativeChatFileAttachments(args: { showToast: ShowToast }): MobileNativeChatFileAttachments { const { client, sessionId, scopeKey, enabled, showToast } = args - const [filesByScope, setFilesByScope] = useState({}) + const picker = useMediaPicker() - const idCounter = useRef(0) - const files = (scopeKey ? filesByScope[scopeKey] : undefined) ?? NO_FILES + const files = useMobileNativeChatDraftInputs(scopeKey).files const updateScope = useCallback( ( - scope: string, + scope: string | MobileNativeChatDraftDestination, next: (current: readonly PendingNativeChatFile[]) => readonly PendingNativeChatFile[] ) => - setFilesByScope((prev) => withScopeAttachments(prev, scope, next(prev[scope] ?? NO_FILES))), + updateMobileNativeChatDraftInputs(scope, (current) => ({ + ...current, + files: next(current.files) + })), [] ) @@ -47,28 +52,43 @@ export function useMobileNativeChatFileAttachments(args: { if (!client || !sessionId || !scope || !enabled) { return } - await uploadPickedNativeChatFiles({ - picked: picker.pickFiles(), - upload: (file) => - uploadMobileAgentSessionAttachment(client, { - sessionId, - name: file.name, - base64: file.base64 - }), - nextId: () => { - idCounter.current += 1 - return `file-${idCounter.current}` - }, - onPicked: (file) => updateScope(scope, (current) => [...current, file]), - onStored: (id, stored) => - updateScope(scope, (current) => - current.map((file) => - file.id === id ? { ...file, name: stored.name, path: stored.path } : file - ) - ), - onFailed: (id) => updateScope(scope, (current) => current.filter((file) => file.id !== id)), - onError: (message) => showToast(message) - }) + const destination = mobileNativeChatDraftDestination(scope) + updateMobileNativeChatDraftInputs(destination, (current) => ({ + ...current, + pending: (current.pending ?? 0) + 1 + })) + try { + await uploadPickedNativeChatFiles({ + canContinue: () => destination.active, + picked: picker.pickFiles(), + upload: (file) => + uploadMobileAgentSessionAttachment(client, { + sessionId, + name: file.name, + base64: file.base64 + }), + nextId: createUuidV4, + onPicked: (file) => updateScope(destination, (current) => [...current, file]), + onStored: (id, stored) => + updateScope(destination, (current) => + current.map((file) => + file.id === id ? { ...file, name: stored.name, path: stored.path } : file + ) + ), + onFailed: (id) => + updateScope(destination, (current) => current.filter((file) => file.id !== id)), + onError: (message) => { + if (destination.active) { + showToast(message) + } + } + }) + } finally { + updateMobileNativeChatDraftInputs(destination, (current) => ({ + ...current, + pending: Math.max(0, (current.pending ?? 0) - 1) + })) + } }, [client, enabled, picker, scopeKey, sessionId, showToast, updateScope]) const removeFiles = useCallback( diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-attachments.test.ts index f8adfe3f6a3a..5fb7a1812bb4 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-attachments.test.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-attachments.test.ts @@ -1,3 +1,4 @@ +import { resetMobileNativeChatDraftInputsForTests } from './mobile-native-chat-draft-inputs' import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { buildAgentTuiClearInputForText } from '../../../../../src/shared/agent-tui-input-clear' @@ -10,6 +11,8 @@ import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-im // Stub the media seam so direct Expo imports would expose unsupported Flow syntax and fail the transform. const pick = vi.hoisted(() => vi.fn()) +const ids = vi.hoisted(() => ({ next: 0 })) +vi.mock('../../../../../src/shared/uuid-v4', () => ({ createUuidV4: () => `img-${++ids.next}` })) vi.mock('../../../platform/media-picker', () => ({ useMediaPicker: () => ({ pickImages: pick }) })) function ok(id: string, result: unknown): RpcSuccess { @@ -85,6 +88,8 @@ describe('useMobileNativeChatImageAttachments', () => { } beforeEach(() => { + resetMobileNativeChatDraftInputsForTests() + ids.next = 0 pick.mockReset() resetMobileNativeChatStaleInputForTests() resetMobileNativeChatTerminalWritesForTests() diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-attachments.ts index 196c1d93186e..ca65dffda3f4 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-attachments.ts @@ -1,17 +1,16 @@ -import { useCallback, useRef, useState } from 'react' +import { createUuidV4 } from '../../../../../src/shared/uuid-v4' +import { + readMobileNativeChatDraftInputs, + useMobileNativeChatDraftInputs, + updateMobileNativeChatDraftInputs, + type MobileNativeChatDraftDestination +} from './mobile-native-chat-draft-inputs' +import { useCallback, useMemo } from 'react' import { buildAgentTuiClearInputForText } from '../../../../../src/shared/agent-tui-input-clear' import type { RpcClient } from '../../../transport/rpc-client' import type { ConnectionState } from '../../../transport/types' import type { MobileImageSource } from '../../../platform/media-picker-contract' -import { - appendPendingNativeChatImages, - type PendingNativeChatImage -} from './mobile-native-chat-image-attachment' -import { - NO_NATIVE_CHAT_IMAGE_ATTACHMENTS, - withScopeAttachments, - type MobileNativeChatImagesByScope -} from './mobile-native-chat-image-scope-state' +import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS, pasteMobileNativeChatImagePaths @@ -119,16 +118,21 @@ export function useMobileNativeChatImageAttachments({ onError, sleep = defaultSleep }: Args): MobileNativeChatImageAttachments { - const [attachmentsByScope, setAttachmentsByScope] = useState({}) - const idCounter = useRef(0) - const attachments = - (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_NATIVE_CHAT_IMAGE_ATTACHMENTS + const inputs = useMobileNativeChatDraftInputs(scopeKey) + const images = inputs.images + const attachments = useMemo(() => [...images], [images]) const addUploadedImages = useCallback( - (scope: string, uploadedImages: Omit[]) => { - setAttachmentsByScope((prev) => ({ - ...prev, - [scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter) + ( + scope: MobileNativeChatDraftDestination, + uploadedImages: Omit[] + ) => { + updateMobileNativeChatDraftInputs(scope, (current) => ({ + ...current, + images: [ + ...current.images, + ...uploadedImages.map((image) => ({ ...image, id: createUuidV4() })) + ] })) }, [] @@ -153,13 +157,10 @@ export function useMobileNativeChatImageAttachments({ if (!scope) { return } - setAttachmentsByScope((prev) => - withScopeAttachments( - prev, - scope, - (prev[scope] ?? []).filter((attachment) => attachment.id !== id) - ) - ) + updateMobileNativeChatDraftInputs(scope, (current) => ({ + ...current, + images: current.images.filter((attachment) => attachment.id !== id) + })) }, [scopeKey] ) @@ -180,8 +181,10 @@ export function useMobileNativeChatImageAttachments({ const sentAt = Date.now() try { const scope = scopeKey - const pendingImages = - (scope ? attachmentsByScope[scope] : undefined) ?? NO_NATIVE_CHAT_IMAGE_ATTACHMENTS + if ((readMobileNativeChatDraftInputs(scope).pending ?? 0) > 0) { + return false + } + const pendingImages = attachments // Hold Send until all selected files finish uploading. const storedFiles = structuredNativeChat ? files.flatMap((file) => @@ -202,13 +205,10 @@ export function useMobileNativeChatImageAttachments({ ) if (outcome !== 'rejected') { const sentIds = new Set(pendingImages.map((attachment) => attachment.id)) - setAttachmentsByScope((prev) => - withScopeAttachments( - prev, - scope, - (prev[scope] ?? []).filter((attachment) => !sentIds.has(attachment.id)) - ) - ) + updateMobileNativeChatDraftInputs(scope, (current) => ({ + ...current, + images: current.images.filter((attachment) => !sentIds.has(attachment.id)) + })) onFilesSent?.(storedFiles.map((file) => file.id)) } return outcome !== 'rejected' @@ -290,13 +290,10 @@ export function useMobileNativeChatImageAttachments({ if (outcome !== 'rejected') { // Drop only chips in this send, including unknown delivery to avoid duplicate retries; preserve newly attached chips. const sentIds = new Set(pendingImages.map((attachment) => attachment.id)) - setAttachmentsByScope((prev) => - withScopeAttachments( - prev, - scope, - (prev[scope] ?? []).filter((attachment) => !sentIds.has(attachment.id)) - ) - ) + updateMobileNativeChatDraftInputs(scope, (current) => ({ + ...current, + images: current.images.filter((attachment) => !sentIds.has(attachment.id)) + })) } return outcome !== 'rejected' } catch { @@ -314,7 +311,7 @@ export function useMobileNativeChatImageAttachments({ }, [ activeHandleRef, - attachmentsByScope, + attachments, baseSend, client, connState, @@ -331,5 +328,11 @@ export function useMobileNativeChatImageAttachments({ ] ) - return { attachments, isAttaching, attachImage, removeAttachment, sendNativeChat } + return { + attachments, + isAttaching: isAttaching || (inputs.pending ?? 0) > 0, + attachImage, + removeAttachment, + sendNativeChat + } } diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-upload.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-upload.ts index c450d1215e96..03b87955312f 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-upload.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-upload.ts @@ -1,3 +1,8 @@ +import { + mobileNativeChatDraftDestination, + updateMobileNativeChatDraftInputs, + type MobileNativeChatDraftDestination +} from './mobile-native-chat-draft-inputs' import { useCallback, useLayoutEffect, useRef, useState } from 'react' import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../../../src/shared/clipboard-image' import type { RpcClient } from '../../../transport/rpc-client' @@ -24,7 +29,10 @@ export function useMobileNativeChatImageUpload(args: { scopeKey: string | null structuredNativeChat: boolean showToast: ShowToast - onImagesUploaded: (scope: string, images: UploadedNativeChatImage[]) => void + onImagesUploaded: ( + scope: MobileNativeChatDraftDestination, + images: UploadedNativeChatImage[] + ) => void onAttachSuccess?: () => void onError?: () => void }): { @@ -62,11 +70,17 @@ export function useMobileNativeChatImageUpload(args: { ) { return } + const destination = mobileNativeChatDraftDestination(scope) + updateMobileNativeChatDraftInputs(destination, (inputs) => ({ + ...inputs, + pending: (inputs.pending ?? 0) + 1 + })) let started = false const uploadedImages: UploadedNativeChatImage[] = [] let uploadError: unknown = null try { await uploadMobileNativeChatImages(source, { + canContinue: () => destination.active, client, getConnectionId: getActiveWorktreeConnectionId, pickImages: picker.pickImages, @@ -88,10 +102,14 @@ export function useMobileNativeChatImageUpload(args: { } } if (uploadedImages.length > 0) { - onImagesUploaded(scope, uploadedImages) + onImagesUploaded(destination, uploadedImages) onAttachSuccess?.() } - if (uploadError !== null) { + updateMobileNativeChatDraftInputs(destination, (inputs) => ({ + ...inputs, + pending: Math.max(0, (inputs.pending ?? 0) - 1) + })) + if (uploadError !== null && destination.active) { const message = uploadError instanceof Error ? uploadError.message : String(uploadError) onError?.() if (connStateRef.current !== 'connected') { diff --git a/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessageEditor.tsx b/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessageEditor.tsx deleted file mode 100644 index 7268b2690f9f..000000000000 --- a/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessageEditor.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { useEffect, useRef, useState } from 'react' -import { - ActivityIndicator, - Pressable, - StyleSheet, - Text, - TextInput, - View, - type StyleProp, - type ViewStyle -} from 'react-native' -import { Check, X } from 'lucide-react-native' -import { colors, radii, spacing, typography } from '../../../theme/mobile-theme' -import { themedStyleSheet } from '../../../theme/themed-styles' -import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme' -import { mobileNativeChatInputStyles } from '../composer/mobile-native-chat-input-styles' -import type { MobileQueuedMessageInlineEditor } from '../../use-mobile-structured-queued-edit' -import { useTextInputContentHeight } from '../../../platform/text-input-content-height' - -const BUTTON_SIZE = 28 -const ICON_SIZE = 16 -const LINE_HEIGHT = 20 -const FIELD_PADDING_VERTICAL = 7 -const FIELD_BORDER = 1 -const FIELD_MIN_HEIGHT = LINE_HEIGHT + 2 * (FIELD_PADDING_VERTICAL + FIELD_BORDER) -const FIELD_MAX_HEIGHT = 4 * LINE_HEIGHT + 2 * (FIELD_PADDING_VERTICAL + FIELD_BORDER) -const FIELD_GAP = 6 -const BUTTON_GAP = spacing.xs -// react-native-web drops hitSlop, so each button's own box is the 44pt-tall touch target. -const TOUCH_TARGET_HEIGHT = 44 -const TARGET_OVERHANG = (TOUCH_TARGET_HEIGHT - FIELD_MIN_HEIGHT) / 2 - -export function MobileNativeChatQueuedMessageEditor({ - editor, - attribution -}: { - editor: MobileQueuedMessageInlineEditor - attribution: string | null -}): React.JSX.Element { - useMobileColorScheme() - const input = useRef(null) - const contentHeight = useTextInputContentHeight(input, editor.text) - const [focused, setFocused] = useState(false) - useEffect(() => { - input.current?.focus() - }, []) - const saveDisabled = editor.saving || !editor.canSave - const saveOff = !editor.canSave && !editor.saving - return ( - - {attribution ? ( - - {attribution} - - ) : null} - - setFocused(true)} - onBlur={() => setFocused(false)} - /> - - - - void editor.save()} - > - {editor.saving ? ( - - ) : ( - - )} - - - - ) -} - -function EditorButton({ - label, - disabled, - busy, - targetStyle, - squareStyle, - pressedStyle, - onPress, - children -}: { - label: string - disabled: boolean - busy?: boolean - targetStyle: StyleProp - squareStyle: StyleProp - pressedStyle: StyleProp - onPress: () => void - children: React.ReactNode -}): React.JSX.Element { - useMobileColorScheme() - // Style the inner square separately because Pressable owns the larger invisible target. - const [pressed, setPressed] = useState(false) - return ( - setPressed(true)} - onPressOut={() => setPressed(false)} - > - - {children} - - - ) -} - -const styles = themedStyleSheet(() => ({ - editor: { - paddingVertical: spacing.sm, - paddingLeft: spacing.md, - paddingRight: spacing.sm, - gap: spacing.xs - }, - attribution: { - color: colors.textMuted, - fontSize: typography.metaSize, - lineHeight: typography.metaSize + spacing.xs - }, - // Bottom-align with the last text line and keep overhanging targets inside editor padding. - line: { - flexDirection: 'row', - alignItems: 'flex-end', - marginVertical: -TARGET_OVERHANG, - marginRight: -spacing.sm - }, - // Keep border width constant so focus cannot shift text. - field: { - minHeight: FIELD_MIN_HEIGHT, - maxHeight: FIELD_MAX_HEIGHT, - lineHeight: LINE_HEIGHT, - paddingTop: FIELD_PADDING_VERTICAL, - paddingBottom: FIELD_PADDING_VERTICAL, - paddingHorizontal: 10, - borderWidth: FIELD_BORDER, - borderColor: colors.borderSubtle, - marginVertical: TARGET_OVERHANG - }, - fieldFocused: { - borderColor: colors.accentBlue - }, - target: { - height: TOUCH_TARGET_HEIGHT, - justifyContent: 'center' - }, - cancelTarget: { - paddingLeft: FIELD_GAP, - paddingRight: BUTTON_GAP / 2 - }, - saveTarget: { - paddingLeft: BUTTON_GAP / 2, - paddingRight: spacing.sm - }, - button: { - width: BUTTON_SIZE, - height: BUTTON_SIZE, - borderRadius: radii.input, - alignItems: 'center', - justifyContent: 'center' - }, - buttonRaised: { - backgroundColor: colors.bgRaised, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle - }, - cancelPressed: { - backgroundColor: colors.bgPanel - }, - save: { - backgroundColor: colors.textPrimary - }, - savePressed: { - backgroundColor: colors.textSecondary - }, - disabled: { opacity: 0.5 } -})) diff --git a/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.test.tsx index d2c58861cf75..37f2f5777db9 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.test.tsx @@ -1,11 +1,17 @@ +import { + mobileQueuedMessageCards, + type MobileQueuedMessageCard +} from '../../mobile-structured-queued-message-cards' +import { queuedDraft } from '../../use-mobile-structured-agent-session-queued.test-fixture' import { createElement } from 'react' +import { findByHostType } from '../../../test-support/react-test-renderer-host-nodes' +import { FakeSession } from '../../../transport/mobile-endpoint-supervisor-test-fakes' +import type { MobileQueuedMessageInlineEditor } from '../../mobile-queued-message-edit-draft' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' import type { ActionSheetAction } from '../../../components/ActionSheetModal' import { colors } from '../../../theme/mobile-theme' import { MobileNativeChatQueuedMessages } from './MobileNativeChatQueuedMessages' -import type { MobileQueuedMessageCard } from '../../mobile-structured-queued-message-cards' -import { mobileQueuedMessageCards } from '../../mobile-structured-queued-message-cards' import { useMobileQueuedMessageComposerActions } from '../../use-mobile-queued-message-composer-actions' import type { MobileStructuredAgentMutate } from '../../use-mobile-structured-agent-mutation' import type { @@ -38,6 +44,7 @@ vi.mock('react-native', () => ({ })) vi.mock('lucide-react-native', () => ({ + Image: 'Image', AlertCircle: 'AlertCircle', Check: 'Check', ChevronDown: 'ChevronDown', @@ -54,6 +61,29 @@ vi.mock('lucide-react-native', () => ({ vi.mock('../../../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) +vi.mock('../../MobileQueuedMessageComposer', () => ({ + MobileQueuedMessageComposer: 'QueuedMessageComposer' +})) + +function inlineEditor( + fields: Partial & { messageId: string } +): MobileQueuedMessageInlineEditor { + return { + scopeKey: 'edit-scope', + sessionId: 'session', + client: new FakeSession('connected'), + imagesCapable: true, + text: 'edited text', + saving: false, + canSave: true, + change: vi.fn(), + save: vi.fn(async () => true), + removeImage: vi.fn(), + cancel: vi.fn(), + ...fields + } +} + function card( overrides: Partial & { messageId: string } ): MobileQueuedMessageCard { @@ -108,75 +138,44 @@ describe('MobileNativeChatQueuedMessages', () => { return mounted.root.find((node) => String(node.type) === 'ActionSheetModal') } - it('edits in place of the card row with an X and a checkmark while keeping another card visible', async () => { - const change = vi.fn() - const save = vi.fn(async () => true) - const cancel = vi.fn() + it('mounts the real composer boundary in the edited row and leaves other cards visible', async () => { + const editor = inlineEditor({ messageId: 'a' }) const mounted = await mount({ - cards: [ - card({ - messageId: 'a', - attribution: 'From Alex', - caption: 'Returned', - needsAttention: true, - state: 'returned' - }), - card({ messageId: 'b' }) - ], - editor: { - messageId: 'a', - text: 'edited text', - saving: false, - canSave: true, - change, - save, - cancel - } + cards: [card({ messageId: 'a', attribution: 'From Alex' }), card({ messageId: 'b' })], + editor }) - const input = mounted.root.findByProps({ accessibilityLabel: 'Edit queued message' }) - expect(input.props.value).toBe('edited text') - const editedRow = mounted.root.findAllByProps({ testID: 'queued-card-row' })[0]! - expect(texts(mounted)).toContain('From Alex') - expect(texts(mounted)).not.toContain('Editing message') - expect(texts(mounted)).not.toContain('Returned') - expect(nodeTypes(editedRow)).toEqual(expect.arrayContaining(['X', 'Check'])) - expect(nodeTypes(editedRow)).not.toContain('Pencil') - expect(nodeTypes(editedRow)).not.toContain('AlertCircle') - expect( - editedRow.findAll((node) => node.props.accessibilityLabel === 'Delete this queued message') - ).toHaveLength(0) - act(() => input.props.onChangeText('updated text')) - expect(change).toHaveBeenCalledWith('updated text') - const button = (label: string) => mounted.root.findByProps({ accessibilityLabel: label }) - await act(async () => button('Save edited message').props.onPress()) - expect(save).toHaveBeenCalledOnce() - act(() => button('Cancel editing').props.onPress()) - expect(cancel).toHaveBeenCalledOnce() + const child = findByHostType(mounted.root, 'QueuedMessageComposer') + expect(child.props.editor).toBe(editor) + expect(child.props.attribution).toBe('From Alex') expect(mounted.root.findAllByProps({ testID: 'queued-card-row' })).toHaveLength(2) expect(mounted.root.findByProps({ accessibilityLabel: 'More actions' }).props.disabled).toBe( true ) - const editAgain = (state: { saving: boolean; canSave: boolean }) => - act(async () => - mounted.update( - createElement(MobileNativeChatQueuedMessages, { - cards: [card({ messageId: 'a' })], - editor: { messageId: 'a', text: '', change, save, cancel, ...state } - }) - ) - ) - await editAgain({ saving: false, canSave: false }) - expect(button('Save edited message').props.disabled).toBe(true) - expect(button('Cancel editing').props.disabled).toBe(false) - await editAgain({ saving: true, canSave: true }) - const saveButton = button('Save edited message') - expect(saveButton.props.disabled).toBe(true) - expect(saveButton.props.accessibilityState).toEqual({ disabled: true }) - expect(saveButton.props['aria-busy']).toBe(true) - expect(nodeTypes(saveButton)).toContain('ActivityIndicator') - expect(nodeTypes(saveButton)).not.toContain('Check') - expect(button('Cancel editing').props.disabled).toBe(true) - expect(button('Edit queued message').props.editable).toBe(false) + }) + + it('projects image-only and mixed queue blocks into the ordinary card line and edit boundary', async () => { + const image = { type: 'image-ref' as const, path: '/host/photo.png', alt: 'Diagram' } + const rows = [ + queuedDraft({ + messageId: 'image-only', + body: { kind: 'message', role: 'user', blocks: [image] } + }), + queuedDraft({ + messageId: 'mixed', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'See this' }, image] } + }) + ] + const cards = mobileQueuedMessageCards(rows, []) + const mounted = await mount({ cards, onSend: vi.fn(async () => true) }) + expect(texts(mounted)).toContain('Diagram') + expect(texts(mounted)).toContain('See this') + expect(cards.map((card) => card.images)).toEqual([[image], [image]]) + const editor = inlineEditor({ messageId: 'image-only', text: '' }) + await act(async () => + mounted.update(createElement(MobileNativeChatQueuedMessages, { cards, editor })) + ) + expect(findByHostType(mounted.root, 'QueuedMessageComposer').props.editor).toBe(editor) + expect(texts(mounted)).toContain('See this') }) it('draws a plain waiting card as one compact row with no caption', async () => { @@ -639,7 +638,7 @@ describe('MobileNativeChatQueuedMessages', () => { try { const mounted = await mountPaused({ pause: { reason: 'stopped' }, - editor: { + editor: inlineEditor({ messageId: 'waiting-1', text: 'next', saving: false, @@ -647,7 +646,7 @@ describe('MobileNativeChatQueuedMessages', () => { change: vi.fn(), save: vi.fn(async () => true), cancel: vi.fn() - } + }) }) const header = mounted.root.findByProps({ testID: 'queued-header' }) for (const label of ['Delete every queued message', 'Resume sending the queued messages']) { diff --git a/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.tsx index 878334d57839..d4664f6f3097 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.tsx +++ b/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.tsx @@ -126,9 +126,9 @@ export function MobileNativeChatQueuedMessages({ const attentionCaption = !expanded ? cards.find((card) => card.needsAttention)?.caption : null const headerCountStyle = [styles.headerCount, attentionCaption && styles.captionReturned] return ( - + {} - + {/* A polite region, as the app's other notices: React Native has no status role. */} card.messageId)} newestIsPersons={!cards.at(-1)?.attribution} + inlineEditorActive={editor !== undefined} > {(reveal, drag) => { const renderRow = (card: MobileQueuedMessageCard, handle?: React.ReactNode) => ( diff --git a/mobile/src/session/native-chat/input/mobile-native-chat-queued-message-styles.ts b/mobile/src/session/native-chat/input/mobile-native-chat-queued-message-styles.ts index 9811456f9a14..add5949536b8 100644 --- a/mobile/src/session/native-chat/input/mobile-native-chat-queued-message-styles.ts +++ b/mobile/src/session/native-chat/input/mobile-native-chat-queued-message-styles.ts @@ -26,6 +26,9 @@ export const queuedMessageStyles = themedStyleSheet(() => ({ borderColor: colors.composerBorder, overflow: 'hidden' }, + // Full editors and menus scroll above the ordinary composer instead of tucking under it. + inlineList: { flexShrink: 1, minHeight: 0, marginBottom: 0 }, + inlineBox: { flexShrink: 1, minHeight: 0, paddingBottom: 0 }, header: { minHeight: HEADER_HEIGHT, flexDirection: 'row', diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 78c33a0529e1..7aafafad5b7d 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -36,6 +36,7 @@ export function useMobileNativeChatController(args: { agentSessionHostSupport?: StructuredAgentSessionHostSupport | null onSendError: Parameters[0]['onSendError'] // Any accepted chat write clears the held failure banner, including answers and permission replies. + onQueuedEditNotice?: (message: string) => void onSendResolved: () => void }): MobileNativeChatController { const { @@ -73,16 +74,14 @@ export function useMobileNativeChatController(args: { // Fixed hook order mounts the lane before drafts; defer Edit’s append through a ref and delete nothing until copying succeeds. const appendComposerTextRef = useRef<(text: string) => boolean>(() => false) const nativeChatLane = useMobileNativeChatSessionLane({ - client, - hostId, + ...args, + composerScopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId), structured: activeChatStructured, resolution: activeChatResolution, sourceIdentity, enabled: showNativeChat, - connState, hostSupport: agentSessionHostSupport, appendComposerTextRef, - onSendError, onActionResolved: onSendResolved }) const { structuredSession: structuredNativeChat, session: nativeChatSession } = nativeChatLane @@ -251,6 +250,8 @@ export function useMobileNativeChatController(args: { }) return { + reportQueuedEditError: onSendError, + chatComposerScopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId), sessionViewReady, isTabChatView, setTabView, diff --git a/mobile/src/session/use-mobile-native-chat-session-lane.ts b/mobile/src/session/use-mobile-native-chat-session-lane.ts index b1fb1138f2f2..0d12a6a3754c 100644 --- a/mobile/src/session/use-mobile-native-chat-session-lane.ts +++ b/mobile/src/session/use-mobile-native-chat-session-lane.ts @@ -12,6 +12,7 @@ import type { MobileNativeChatSendErrorReporter } from './native-chat/use-mobile export function useMobileNativeChatSessionLane({ client, hostId, + composerScopeKey, structured, resolution, sourceIdentity, @@ -20,10 +21,12 @@ export function useMobileNativeChatSessionLane({ enabled, connState, onSendError, + onQueuedEditNotice, onActionResolved }: { client: RpcClient | null hostId: string + composerScopeKey?: string | null structured: boolean resolution: MobileNativeChatResolution | null sourceIdentity: Parameters[0]['sourceIdentity'] @@ -32,6 +35,7 @@ export function useMobileNativeChatSessionLane({ appendComposerTextRef: { readonly current: (text: string) => boolean } enabled: boolean connState: ConnectionState + onQueuedEditNotice?: (message: string) => void onSendError: MobileNativeChatSendErrorReporter onActionResolved?: () => void }): { @@ -65,6 +69,7 @@ export function useMobileNativeChatSessionLane({ sourceIdentity, hostSupport, appendComposerText, + composerScopeKey, enabled, // Holds are connection-scoped; dropping this on transport loss lets the hook // reacquire the provider without clearing the cached transcript. @@ -72,6 +77,7 @@ export function useMobileNativeChatSessionLane({ agent: structured ? agent : null, sessionCommands: sessionCommands.commands, onSendError, + onQueuedEditNotice, ...(onActionResolved ? { onActionResolved } : {}) }) return { diff --git a/mobile/src/session/use-mobile-queued-edit-replacement.test.tsx b/mobile/src/session/use-mobile-queued-edit-replacement.test.tsx new file mode 100644 index 000000000000..ec1ab7e3e23d --- /dev/null +++ b/mobile/src/session/use-mobile-queued-edit-replacement.test.tsx @@ -0,0 +1,321 @@ +import { createElement } from 'react' +import { act } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' +import { agentSessionSendBodyFingerprint } from '../../../src/shared/structured-agent-session-send-mutation' +import { queuedMessageWithEdit } from '../../../src/shared/queued-message-edit' +import { queuedMessageUpdateDecision } from '../../../src/main/native-chat/agent-session-journal/queued-message-update' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import type { RpcResponse } from '../transport/types' +import { + mobileNativeChatDraftDestination, + readMobileNativeChatDraftInputs, + updateMobileNativeChatDraftInputs +} from './native-chat/composer/mobile-native-chat-draft-inputs' +import { setMobileNativeChatDraftText } from './native-chat/composer/mobile-native-chat-draft-store' +import { + SESSION_ID, + batchEvent, + fieldsOf, + mutationOk, + queuedDraft +} from './use-mobile-structured-agent-session-queued.test-fixture' +import { + mobileQueuedEditHarness, + withText, + readOrHoldReply +} from './mobile-queued-edit-hook.test-fixture' + +describe('mobile queued replacement contracts', () => { + const { state, Harness, mount, sendRequest, append, notice, resolved, requestedFingerprint } = + mobileQueuedEditHarness() + it.each(['refused', 'unknown-committed', 'unknown-refused-unchanged'] as const)( + 'restored seed after %s follows host CAS without a stale accepted action', + async (mode) => { + const message = queuedDraft({ + messageId: 'seed', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'base' }] } + }) + await mount(message) + const updates = () => + sendRequest.mock.calls.filter(([method]) => method === 'agentSession.queuedMessageUpdate') + .length + sendRequest.mockImplementation(async (method, params) => { + if (method !== 'agentSession.queuedMessageUpdate') { + return readOrHoldReply(method, params) + } + if (mode === 'refused') { + return mutationOk({ status: 'not-editable', messageId: 'seed' }) + } + throw markRpcDeliveryUnknown(new Error('lost')) + }) + act(() => state.hook!.queued.editor!.change('attempt')) + await act(async () => { + await state.hook!.queued.editor!.save() + }) + if (mode === 'unknown-refused-unchanged') { + sendRequest.mockImplementation(async (method, params) => + method === 'agentSession.queuedMessageUpdate' + ? mutationOk({ status: 'not-editable', messageId: 'seed' }) + : readOrHoldReply(method, params) + ) + act(() => state.hook!.queued.editor!.change('refused later')) + await act(async () => { + await state.hook!.queued.editor!.save() + }) + } + act(() => state.hook!.queued.editor!.change('base')) + const before = updates() + sendRequest.mockImplementation(async (method, params) => { + if (method !== 'agentSession.queuedMessageUpdate') { + return readOrHoldReply(method, params) + } + const body = mode === 'unknown-committed' ? withText(message.body, 'attempt') : message.body + const result = queuedMessageUpdateDecision( + { + messageId: 'seed', + position: 1, + body, + fingerprint: agentSessionSendBodyFingerprint(SESSION_ID, body), + state: 'waiting', + holdReason: null, + returnedReason: null, + returnedRejection: null, + consumedAs: null, + queuedAt: null + }, + SESSION_ID, + { + messageId: 'seed', + expectedBodyFingerprint: String(fieldsOf(params).expectedBodyFingerprint), + text: String(fieldsOf(params).text) + } + ) + if (result.status === 'ready') { + throw new Error('unexpected ready') + } + return mutationOk(result) + }) + await act(async () => { + await state.hook!.queued.editor!.save() + }) + expect(updates()).toBe(before + (mode === 'refused' ? 0 : 1)) + if (mode === 'unknown-refused-unchanged') { + expect(state.hook!.queued.editor).toMatchObject({ + text: 'base', + saving: false, + canSave: true + }) + act(() => state.listener?.(batchEvent([{ ...message }]))) + expect(state.hook!.queued.editor).toBeDefined() + expect(resolved).not.toHaveBeenCalled() + } else { + expect(state.hook!.queued.editor).toBeUndefined() + } + expect(append).toHaveBeenCalledTimes(mode === 'unknown-committed' ? 1 : 0) + expect(notice).toHaveBeenCalledTimes(mode === 'unknown-committed' ? 1 : 0) + } + ) + + it('joins state.menu re-pick after unknown proof to a complete next Save RPC and retained tray', async () => { + const message = queuedDraft({ + messageId: 'rich-proof', + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '$review original' }], + skills: [{ name: 'review', path: '/A/SKILL.md' }] + } + }) + await mount(message) + act(() => state.hook!.queued.editor!.cancel()) + await act(async () => + state.renderer?.update( + createElement(Harness, { + skillsSupport: true, + imagesSupport: true, + composerScopeKey: 'original-bottom' + }) + ) + ) + await act(async () => { + await state.hook!.queued.edit!('rich-proof') + }) + act(() => setMobileNativeChatDraftText('original-bottom', 'occupied')) + sendRequest.mockImplementation(async (method, params) => { + if (method === 'agentSession.queuedMessageUpdate') { + throw markRpcDeliveryUnknown(new Error('lost')) + } + return readOrHoldReply(method, params) + }) + act(() => state.hook!.queued.editor!.change('$review')) + await act(async () => { + await state.hook!.queued.editor!.save() + }) + vi.stubGlobal('structuredClone', undefined) + const key = state.hook!.queued.editor!.scopeKey + const accepted = queuedMessageWithEdit(message.body, { + text: '$review', + skills: [{ name: 'review', path: '/A/SKILL.md' }], + images: [] + }) + if (!accepted.ok) { + throw new Error(accepted.reason) + } + act(() => state.hook!.queued.editor!.change('/rev')) + act(() => state.menu!.onSelectionChange({ start: 4, end: 4 })) + const row = state.menu!.rows.find( + (entry) => entry.kind === 'skill' && entry.path === '/B/SKILL.md' + ) + expect(row).toBeDefined() + act(() => state.menu!.pick(row!)) + act(() => state.hook!.queued.editor!.change(`${state.hook!.queued.editor!.text}suffix`)) + act(() => + updateMobileNativeChatDraftInputs(key, (before) => ({ + ...before, + images: [{ id: 'uploaded', path: '/new.png', previewUri: '/new.png' }] + })) + ) + const inputs = readMobileNativeChatDraftInputs(key) + act(() => state.listener?.(batchEvent([{ ...message, body: accepted.body }]))) + expect(state.hook!.queued.editor).toMatchObject({ + scopeKey: key, + text: '$review suffix', + saving: false + }) + expect(readMobileNativeChatDraftInputs(key)).toEqual(inputs) + expect(state.bottomText).toBe('occupied') + expect(resolved).not.toHaveBeenCalled() + const holds = sendRequest.mock.calls + .filter( + ([method, params]) => + method === 'agentSession.queuedMessageEditHold' && fieldsOf(params).action === 'acquire' + ) + .map(([, params]) => fieldsOf(params)) + expect(holds.at(-1)).toMatchObject({ + expectedBodyFingerprint: agentSessionSendBodyFingerprint(SESSION_ID, accepted.body) + }) + expect(holds.at(-1)?.editId).not.toBe(holds.at(-2)?.editId) + sendRequest.mockImplementation(async (method, params) => + method === 'agentSession.queuedMessageUpdate' + ? mutationOk({ + status: 'updated', + messageId: message.messageId, + fingerprint: requestedFingerprint(message) + }) + : readOrHoldReply(method, params) + ) + await act(async () => { + await state.hook!.queued.editor!.save() + }) + const update = sendRequest.mock.calls.findLast( + ([method]) => method === 'agentSession.queuedMessageUpdate' + ) + expect(fieldsOf(update?.[1])).toMatchObject({ + text: '$review suffix', + skills: [{ name: 'review', path: '/B/SKILL.md' }], + images: [{ type: 'image-ref', path: '/new.png' }], + expectedBodyFingerprint: agentSessionSendBodyFingerprint(SESSION_ID, accepted.body) + }) + expect(state.hook!.queued.editor).toBeUndefined() + expect(resolved).toHaveBeenCalledOnce() + expect(state.bottomText).toBe('occupied') + }) + it('hands back a full retained replacement and pending operations once with exact accepted-image exclusion', async () => { + const message = queuedDraft({ + messageId: 'rebase-images', + body: { + kind: 'message', + role: 'user', + blocks: [ + { type: 'text', text: 'original' }, + { type: 'image-ref', path: '/original.png', alt: 'original' } + ] + } + }) + await mount(message) + act(() => state.hook!.queued.editor!.cancel()) + await act(async () => + state.renderer?.update( + createElement(Harness, { imagesSupport: true, composerScopeKey: 'original-bottom' }) + ) + ) + await act(async () => { + await state.hook!.queued.edit!('rebase-images') + }) + act(() => setMobileNativeChatDraftText('original-bottom', 'occupied')) + const key = state.hook!.queued.editor!.scopeKey + act(() => { + state.hook!.queued.editor!.change('submitted') + updateMobileNativeChatDraftInputs(key, (before) => ({ + ...before, + images: [ + ...before.images, + { id: 'uploaded', path: '/accepted.png', previewUri: '/accepted.png' } + ] + })) + }) + let finish: ((response: RpcResponse) => void) | undefined + sendRequest.mockImplementation((method, params) => + method === 'agentSession.queuedMessageUpdate' + ? new Promise((resolve) => { + finish = resolve + }) + : Promise.resolve(readOrHoldReply(method, params)) + ) + let saving: Promise | undefined + act(() => { + saving = state.hook!.queued.editor!.save() + }) + const voice = state.hook!.queued.editor!.insertDictation! + act(() => { + voice('newer') + }) + await act(async () => { + finish!( + mutationOk({ + status: 'updated', + messageId: message.messageId, + fingerprint: requestedFingerprint(message) + }) + ) + await saving + }) + expect(state.hook!.queued.editor).toMatchObject({ text: 'submitted newer', saving: false }) + expect(resolved).not.toHaveBeenCalled() + const operation = mobileNativeChatDraftDestination(key) + act(() => + updateMobileNativeChatDraftInputs(operation, (before) => ({ + ...before, + images: [ + ...before.images.filter(({ id }) => id !== 'uploaded'), + { id: 'uploaded', path: '/changed.png', previewUri: '/changed.png' } + ], + files: [{ id: 'file', name: 'report.txt', path: null, mime: 'text/plain', byteLength: 3 }], + pending: 1 + })) + ) + act(() => state.listener?.(batchEvent([]))) + expect(state.hook!.queued.editor).toBeUndefined() + expect(state.bottomText).toBe('occupied\n\nsubmitted newer') + expect(readMobileNativeChatDraftInputs('original-bottom')).toMatchObject({ + images: [{ id: 'uploaded', path: '/changed.png' }], + pending: 1 + }) + act(() => + updateMobileNativeChatDraftInputs(operation, (before) => ({ + ...before, + files: before.files.map((file) => ({ ...file, path: '/report.txt' })), + pending: 0 + })) + ) + act(() => { + voice('newer') + state.listener?.(batchEvent([])) + }) + expect(state.bottomText).toBe('occupied\n\nsubmitted newer newer') + expect(readMobileNativeChatDraftInputs('original-bottom').files).toMatchObject([ + { path: '/report.txt' } + ]) + expect(notice).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/session/use-mobile-session-image-attachments.test.tsx b/mobile/src/session/use-mobile-session-image-attachments.test.tsx index 2719eaf2ccf7..b99ace3c67a9 100644 --- a/mobile/src/session/use-mobile-session-image-attachments.test.tsx +++ b/mobile/src/session/use-mobile-session-image-attachments.test.tsx @@ -1,4 +1,5 @@ import { createElement } from 'react' +import { resetMobileNativeChatDraftInputsForTests } from './native-chat/composer/mobile-native-chat-draft-inputs' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes' @@ -66,6 +67,7 @@ describe('useMobileSessionImageAttachments', () => { } beforeEach(() => { + resetMobileNativeChatDraftInputsForTests() vi.useFakeTimers() resetMobileNativeChatStaleInputForTests() resetMobileNativeChatTerminalWritesForTests() @@ -85,7 +87,7 @@ describe('useMobileSessionImageAttachments', () => { await hook.nativeChatImages.attachImage('library') }) expect(hook.nativeChatImages.attachments).toEqual([ - { id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.jpg' } + { id: expect.any(String), path: '/tmp/a.png', previewUri: 'file:///a.jpg' } ]) } @@ -102,13 +104,14 @@ describe('useMobileSessionImageAttachments', () => { it('sends structured session images without a terminal input lease', async () => { const args = baseArgs({ activeHandle: null, activeHandleRef: { current: null } }) await renderWithImage(args) + const image = hook.nativeChatImages.attachments[0] expect(await send()).toBe(true) expect(args.nativeChatBaseSend).toHaveBeenCalledWith( 'look', ['file:///a.jpg'], expect.any(Number), - [{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.jpg' }] + [image] ) expect(args.client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ 'clipboard.startImageUpload', diff --git a/mobile/src/session/use-mobile-session-native-chat-dictation.ts b/mobile/src/session/use-mobile-session-native-chat-dictation.ts index a0dfc71ba7cf..c173eeac3b36 100644 --- a/mobile/src/session/use-mobile-session-native-chat-dictation.ts +++ b/mobile/src/session/use-mobile-session-native-chat-dictation.ts @@ -1,3 +1,4 @@ +import { captureMobileNativeChatTextDestination } from './native-chat/composer/mobile-native-chat-draft-inputs' import { useEffect, useCallback, useRef } from 'react' import { AppState } from 'react-native' import { useFocusEffect } from 'expo-router' @@ -81,6 +82,7 @@ export function useMobileSessionNativeChatDictation( connState, agentSessionHostSupport, onSendError: nativeChatSendError.show, + onQueuedEditNotice: showToast, onSendResolved: nativeChatSendError.clear }) const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController @@ -114,16 +116,25 @@ export function useMobileSessionNativeChatDictation( [setShowDictationSetup, showToast] ) + const chatDictationDestination = useRef<((text: string) => boolean | void) | undefined>(undefined) const dictationTargetReady = showNativeChat ? connState === 'connected' : canSend const dictation = useMobileDictation({ client, enabled: dictationTargetReady && dictationEnabled, onTranscript: (text) => { // Route dictation to the visible chat composer locally; terminal mode retains live-input routing. - if (showNativeChatRef.current) { - nativeChatController.setChatComposerText((current) => - appendBufferedDictation(current, text) - ) + if (showNativeChatRef.current || chatDictationDestination.current) { + const insert = chatDictationDestination.current + chatDictationDestination.current = undefined + if (insert) { + if (insert(text) === false) { + return + } + } else { + nativeChatController.setChatComposerText((current) => + appendBufferedDictation(current, text) + ) + } showToast('Dictation inserted') return } @@ -160,6 +171,7 @@ export function useMobileSessionNativeChatDictation( }) const startDictation = useCallback(() => { + chatDictationDestination.current = captureMobileNativeChatTextDestination() const routeContext = activeHandle ? { handle: activeHandle, liveInputEnabled: liveInputTerminalHandles.has(activeHandle) } : null diff --git a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx index 208383bf865d..be178992178e 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx @@ -1,3 +1,7 @@ +import { + updateMobileNativeChatDraftInputs, + resetMobileNativeChatDraftInputsForTests +} from './native-chat/composer/mobile-native-chat-draft-inputs' import { createElement, useState } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -12,6 +16,9 @@ import { import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' +import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge' +import { useMobileNativeChatDrafts } from './native-chat/composer/use-mobile-native-chat-drafts' +import type { AgentSessionSlashCommand } from '../../../src/shared/agent-session-wire' import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY, AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY @@ -61,6 +68,8 @@ describe('mobile structured send actions', () => { let listener: ((value: unknown) => void) | null = null let storedOperations: Map let agent = 'codex' + let sessionCommands: readonly AgentSessionSlashCommand[] | undefined + let sendOrdinary: ReturnType['sendWithOutcome'] let hostSupport: StructuredAgentSessionHostSupport | null = null const onSendError = vi.fn() const sendRequest = vi.fn() @@ -104,18 +113,39 @@ describe('mobile structured send actions', () => { client, sessionId: 'session-1', sourceIdentity: 'host-a\0workspace-a', + composerScopeKey: 'ordinary-draft', enabled: true, connected: true, agent, + sessionCommands, hostSupport, onSendError }) return createElement('span', null, banner.message) } - async function mountSession(): Promise { + function BridgeHarness() { + const drafts = useMobileNativeChatDrafts({ + hostId: 'host-a', + worktreeId: 'workspace-a', + tabId: 'performance', + sessionId: 'session-1', + messages: [], + transcriptSettled: true + }) + sendOrdinary = useMobileStructuredNativeChatSendBridge({ + ...drafts, + agent: 'codex', + sessionCommands, + sendStructured: (...args) => hook!.sendWithOutcome(...args), + onSendError + }).sendWithOutcome + return createElement(Harness) + } + + async function mountSession(ordinary = false): Promise { act(() => { - renderer = create(createElement(Harness)) + renderer = create(createElement(ordinary ? BridgeHarness : Harness)) }) await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) act(() => listener?.(snapshotEvent())) @@ -159,9 +189,50 @@ describe('mobile structured send actions', () => { banner = null listener = null agent = 'codex' + sessionCommands = undefined hostSupport = null + resetMobileNativeChatDraftInputsForTests() }) + it.each([10, 100])( + 'ordinary phone bridge and Send skip %i commands for prose but still route slash controls', + async (count) => { + let kinds = 0 + let names = 0 + sessionCommands = Array.from({ length: count }, (_, index) => ({ + get kind() { + kinds++ + return 'command' as const + }, + get name() { + names++ + return `provider-${index}` + } + })) + sendRequest.mockImplementation(async (method) => + method === 'agentSession.send' ? sendResult('accepted') : ok({}) + ) + await mountSession(true) + kinds = 0 + names = 0 + await act(async () => { + expect(await sendOrdinary('hello')).toBe('accepted') + }) + expect(calls()).toHaveLength(1) + expect(fieldsOf(calls()[0]![1]).body).toMatchObject({ + blocks: [{ type: 'text', text: 'hello' }] + }) + expect({ kinds, names }).toEqual({ kinds: 0, names: 0 }) + await act(async () => { + expect(await sendOrdinary('/compact extra')).toBe('rejected') + }) + expect(calls()).toHaveLength(1) + expect(onSendError).toHaveBeenCalledWith('Use /compact without arguments.', undefined) + expect(kinds).toBeGreaterThanOrEqual(count) + expect(names).toBeGreaterThanOrEqual(count) + } + ) + it('selects a native OMP skill and sends the exact token and arguments through RPC', async () => { agent = 'omp' sendRequest.mockImplementation(async (method) => @@ -518,6 +589,23 @@ describe('mobile structured send actions', () => { } ) + it('does not infer omitted captured skills from the live composer draft', async () => { + hostSupport = structuredAgentSessionHostSupport([AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY]) + sendRequest.mockImplementation(async (method) => + method === 'agentSession.send' ? sendResult('accepted') : ok({ models: [], current: {} }) + ) + updateMobileNativeChatDraftInputs('ordinary-draft', () => ({ + images: [], + files: [], + skills: [{ name: 'review', path: '/live/SKILL.md' }] + })) + await mountSession() + await act(async () => { + await hook!.sendWithOutcome('$review text') + }) + expect(fieldsOf(calls()[0]?.[1]).body).not.toHaveProperty('skills') + }) + it('sends an attached file as a reference after the text, and only images as image blocks', async () => { sendRequest.mockImplementation(async (method) => method === 'agentSession.send' ? sendResult('accepted') : ok({ models: [], current: {} }) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 7a34a3200ab7..a9cdd97d8bd8 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -53,7 +53,6 @@ export function useMobileStructuredAgentSession( ): StructuredMobileSession { const { agent, - appendComposerText, client, connected, sessionId, @@ -220,6 +219,8 @@ export function useMobileStructuredAgentSession( [approvalPrompt, questionPrompt, queueSendsNext, turnId] ) const queued = useMobileStructuredQueuedMessageControls({ + ...args, + appendComposerText: args.appendComposerText, sessionKey, agentName: agent ? (tuiAgentDisplayName(agent) ?? agent) : undefined, journalItems: state.items, @@ -227,15 +228,13 @@ export function useMobileStructuredAgentSession( queuePause, submissions: state.submissions, agentWorking: isWorking, - client, - sessionId, + imagesCapable: hostSupport?.queuedMessageImageEdit === true, + skillsCapable: hostSupport?.queuedMessageSkillsEdit === true, reorderCapable: hostSupport?.queuedMessageReorder === true, editCapable: hostSupport ? hostSupport.queuedMessageEdit === true : null, chatActive: enabled, enabled: enabled && connected, mutate, - appendComposerText, - onSendError, ...(onActionResolved ? { onActionResolved } : {}) }) // Stop preserves host-held cards under queue pause; only a later user action resumes them. diff --git a/mobile/src/session/use-mobile-structured-queued-edit.test.tsx b/mobile/src/session/use-mobile-structured-queued-edit.test.tsx index a93901ec9c37..ce80299c06a1 100644 --- a/mobile/src/session/use-mobile-structured-queued-edit.test.tsx +++ b/mobile/src/session/use-mobile-structured-queued-edit.test.tsx @@ -1,134 +1,48 @@ +import type { RpcClient } from '../transport/rpc-client' import { createElement } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { - AgentSessionQueuedMessage, - AgentSessionQueuedMessageEditHoldResult -} from '../../../src/shared/agent-session-wire' +import { act } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' import { agentSessionSendBodyFingerprint } from '../../../src/shared/structured-agent-session-send-mutation' import { queuedMessageWithEdit } from '../../../src/shared/queued-message-edit' -import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse } from '../transport/types' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' -import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' import { - CAPABLE, + mobileNativeChatDraftDestination, + readMobileNativeChatDraftInputs, + updateMobileNativeChatDraftInputs +} from './native-chat/composer/mobile-native-chat-draft-inputs' +import { setMobileNativeChatDraftText } from './native-chat/composer/mobile-native-chat-draft-store' +import { SESSION_ID, batchEvent, fieldsOf, mutationOk, ok, - queuedDraft, - snapshotEvent + queuedDraft } from './use-mobile-structured-agent-session-queued.test-fixture' - -function withText(body: AgentSessionQueuedMessage['body'], text: string) { - const edited = queuedMessageWithEdit(body, { text }) - if (!edited.ok) { - throw new Error(edited.reason) - } - return edited.body -} - -function editHoldReply(params: unknown): RpcResponse { - const result: AgentSessionQueuedMessageEditHoldResult = - fieldsOf(params).action === 'release' - ? { status: 'released' } - : { status: 'held', fingerprint: 'fp', leaseDurationMs: 120_000, remainingMs: 120_000 } - return ok(result) -} - -function readOrHoldReply(method: string, params: unknown): RpcResponse { - return method === 'agentSession.queuedMessageEditHold' - ? editHoldReply(params) - : ok({ models: [], current: {} }) -} - -vi.mock('@react-native-async-storage/async-storage', () => ({ - default: { getItem: async () => null, setItem: async () => {}, removeItem: async () => {} } -})) +import { + editHoldReply, + mobileQueuedEditHarness, + withText, + readOrHoldReply +} from './mobile-queued-edit-hook.test-fixture' describe('mobile in-place queued Edit', () => { - let renderer: ReactTestRenderer | undefined - let hook: ReturnType | undefined - let listener: ((data: unknown) => void) | undefined - const append = vi.fn(() => true) - const error = vi.fn() - const resolved = vi.fn() - const sendRequest = vi.fn() - const client: RpcClient = { + const { + state, + Harness, + mount, + client, sendRequest, - subscribe: (_method, _params, onData) => { - listener = onData - return () => {} - }, - updateTerminalSubscriptionViewport: () => {}, - getState: () => 'connected', - getReconnectAttempt: () => 0, - getLastConnectedAt: () => null, - onStateChange: () => () => {}, - notifyForeground: () => {}, - close: () => {} - } - function Harness({ - sessionId = SESSION_ID, - editSupport = true, - enabled = true, - connected = true, - transport = client - }: { - sessionId?: string - editSupport?: boolean | null - enabled?: boolean - connected?: boolean - transport?: RpcClient | null - }): null { - hook = useMobileStructuredAgentSession({ - client: transport, - sessionId, - sourceIdentity: 'host-a', - enabled, - connected, - agent: 'codex', - hostSupport: - editSupport === null - ? null - : { - ...CAPABLE, - queuedMessages: false, - queuedMessageEdit: editSupport - }, - appendComposerText: append, - onSendError: error, - onActionResolved: resolved - }) - return null - } - async function mount(message: AgentSessionQueuedMessage, editSupport: boolean | null = true) { - vi.clearAllMocks() - sendRequest.mockImplementation(async (method, params) => - method === 'agentSession.queuedMessageUpdate' - ? mutationOk({ status: 'updated', messageId: message.messageId, fingerprint: 'new' }) - : readOrHoldReply(method, params) - ) - await act(async () => { - renderer = create(createElement(Harness, { editSupport })) - }) - act(() => listener?.(snapshotEvent({ queuedMessages: [message], runningTurn: true }))) - if (editSupport !== null) { - await act(async () => { - expect(await hook!.queued.edit!(message.messageId)).toBe(true) - }) - } - } - afterEach(async () => { - await act(async () => renderer?.unmount()) - vi.useRealTimers() - }) - + append, + error, + notice, + resolved, + requestedFingerprint + } = mobileQueuedEditHarness() it('waits for the capability probe before offering the legacy fallback', async () => { await mount(queuedDraft({ messageId: 'draft-1' }), null) - expect(hook!.queued.edit).toBeUndefined() + expect(state.hook!.queued.edit).toBeUndefined() expect( sendRequest.mock.calls.some( ([method]) => @@ -136,10 +50,108 @@ describe('mobile in-place queued Edit', () => { method === 'agentSession.queuedMessageEditHold' ) ).toBe(false) - await act(async () => renderer?.update(createElement(Harness, { editSupport: false }))) - expect(hook!.queued.edit).toEqual(expect.any(Function)) + await act(async () => state.renderer?.update(createElement(Harness, { editSupport: false }))) + expect(state.hook!.queued.edit).toEqual(expect.any(Function)) }) + it.each([ + [false, false], + [true, false], + [false, true], + [true, true] + ])( + 'sends own optional keys only for host image=%s skill=%s capabilities', + async (imagesSupport, skillsSupport) => { + const message = queuedDraft({ + messageId: 'matrix', + body: { + kind: 'message', + role: 'user', + blocks: [ + { type: 'text', text: '$review original' }, + { + type: 'image-ref', + path: '/host/original.png', + alt: 'original' + } + ], + skills: [{ name: 'review', path: '/host/old/SKILL.md' }] + } + }) + await mount(message) + act(() => state.hook!.queued.editor!.cancel()) + await act(async () => + state.renderer!.update(createElement(Harness, { imagesSupport, skillsSupport })) + ) + await act(async () => { + await state.hook!.queued.edit!('matrix') + }) + act(() => state.hook!.queued.editor!.change('$review changed')) + await act(async () => { + expect(await state.hook!.queued.editor!.save()).toBe(true) + }) + const request = fieldsOf( + sendRequest.mock.calls.find( + ([method]) => method === 'agentSession.queuedMessageUpdate' + )?.[1] + ) + expect(Object.hasOwn(request, 'images')).toBe(imagesSupport) + expect(Object.hasOwn(request, 'skills')).toBe(skillsSupport) + if (imagesSupport) { + expect(request.images).toEqual([message.body.blocks[1]]) + } + if (skillsSupport) { + expect(request.skills).toEqual(message.body.skills) + } + // The old-host path leaves image blocks on the host while changing only text. + const edited = queuedMessageWithEdit(message.body, { + text: String(request.text), + ...(imagesSupport + ? { + images: [ + { + type: 'image-ref' as const, + path: '/host/original.png', + alt: 'original' + } + ] + } + : {}), + ...(skillsSupport ? { skills: message.body.skills } : {}) + }) + expect(edited.ok && edited.body.blocks.filter((block) => block.type === 'image-ref')).toEqual( + [message.body.blocks[1]] + ) + } + ) + + it.each([false, true])( + 'clean departure redirects captured dictation and Cancel=%s retires it', + async (cancel) => { + await mount(queuedDraft({ messageId: 'clean-dictation' })) + act(() => state.hook!.queued.editor!.cancel()) + await act(async () => + state.renderer!.update(createElement(Harness, { composerScopeKey: 'original-bottom' })) + ) + await act(async () => { + await state.hook!.queued.edit!('clean-dictation') + }) + const insert = state.hook!.queued.editor!.insertDictation! + if (cancel) { + act(() => state.hook!.queued.editor!.cancel()) + } else { + await act(async () => + state.renderer!.update( + createElement(Harness, { sessionId: 'elsewhere', composerScopeKey: 'elsewhere' }) + ) + ) + } + act(() => setMobileNativeChatDraftText('original-bottom', 'bottom')) + act(() => expect(insert('later')).toBe(!cancel)) + expect(state.bottomText).toBe(cancel ? 'bottom' : 'bottom later') + } + ) + it('holds and renews a card while typing, sends only its edited text, and cancels in place', async () => { const message = queuedDraft({ messageId: 'image-card', @@ -168,9 +180,9 @@ describe('mobile in-place queued Edit', () => { expectedBodyFingerprint: agentSessionSendBodyFingerprint(SESSION_ID, message.body) }) expect(fieldsOf(holdCalls()[1]?.[1])).toMatchObject({ action: 'renew', editId: acquire.editId }) - act(() => hook!.queued.editor!.change('edited')) + act(() => state.hook!.queued.editor!.change('edited')) await act(async () => { - expect(await hook!.queued.editor!.save()).toBe(true) + expect(await state.hook!.queued.editor!.save()).toBe(true) }) const update = sendRequest.mock.calls.find( ([method]) => method === 'agentSession.queuedMessageUpdate' @@ -182,26 +194,26 @@ describe('mobile in-place queued Edit', () => { }) expect(fieldsOf(update?.[1])).not.toHaveProperty('body') const body = withText(message.body, 'edited') - act(() => listener?.(batchEvent([{ ...message, body }]))) - expect(hook!.queued.cards).toMatchObject([{ messageId: 'image-card', text: 'edited' }]) - expect(hook!.queued.editor).toBeUndefined() + act(() => state.listener?.(batchEvent([{ ...message, body }]))) + expect(state.hook!.queued.cards).toMatchObject([{ messageId: 'image-card', text: 'edited' }]) + expect(state.hook!.queued.editor).toBeUndefined() expect(fieldsOf(holdCalls().at(-1)?.[1])).toMatchObject({ action: 'release', editId: acquire.editId }) await act(async () => { - await hook!.queued.edit!('image-card') + await state.hook!.queued.edit!('image-card') }) - act(() => hook!.queued.editor!.change('cancelled')) - await act(async () => hook!.queued.editor!.cancel()) - expect(hook!.queued.cards[0]?.text).toBe('edited') + act(() => state.hook!.queued.editor!.change('cancelled')) + await act(async () => state.hook!.queued.editor!.cancel()) + expect(state.hook!.queued.cards[0]?.text).toBe('edited') expect( sendRequest.mock.calls.some(([method]) => method === 'agentSession.queuedMessageDelete') ).toBe(false) expect(append).not.toHaveBeenCalled() }) - it('hands a stale Save to the composer and reopens on the newer text; closes an unconfirmed one the card holds', async () => { + it('hands a conflicted edit back once and reconciles a lost reply from the published desired body', async () => { const message = queuedDraft({ messageId: 'draft-1' }) await mount(message) const newer = withText(message.body, 'edited elsewhere') @@ -209,48 +221,118 @@ describe('mobile in-place queued Edit', () => { if (method !== 'agentSession.queuedMessageUpdate') { return readOrHoldReply(method, params) } - listener?.(batchEvent([{ ...message, body: newer }])) + state.listener?.(batchEvent([{ ...message, body: newer }])) return mutationOk({ status: 'changed', messageId: 'draft-1' }) }) - act(() => hook!.queued.editor!.change('my text')) + act(() => state.hook!.queued.editor!.change('my text')) await act(async () => { - expect(await hook!.queued.editor!.save()).toBe(false) + expect(await state.hook!.queued.editor!.save()).toBe(false) }) - expect(append).toHaveBeenCalledWith('my text') - expect(error).toHaveBeenCalledWith( - 'This message changed while you were editing. Your edit is in the chat box.' - ) - expect(hook!.queued.editor).toMatchObject({ messageId: 'draft-1', text: 'edited elsewhere' }) - act(() => hook!.queued.editor!.change('saved text')) + expect(append).toHaveBeenCalledExactlyOnceWith('my text') + expect(notice).toHaveBeenCalledExactlyOnceWith('Your edit is in the chat box.') + expect(state.hook!.queued.editor).toBeUndefined() + await act(async () => { + await state.hook!.queued.edit!('draft-1') + }) + act(() => state.hook!.queued.editor!.change('saved text')) const body = withText(message.body, 'saved text') sendRequest.mockImplementation(async (method, params) => { if (method === 'agentSession.queuedMessageUpdate') { - listener?.(batchEvent([{ ...message, body }])) + state.listener?.(batchEvent([{ ...message, body }])) throw markRpcDeliveryUnknown(new Error('reply lost')) } return readOrHoldReply(method, params) }) await act(async () => { - await hook!.queued.editor!.save() + await state.hook!.queued.editor!.save() }) - expect(hook!.queued.editor).toBeUndefined() - expect(hook!.queued.cards[0]?.text).toBe('saved text') - const retry = sendRequest.mock.calls.findLast( - ([method]) => method === 'agentSession.queuedMessageUpdate' - ) - expect(fieldsOf(retry?.[1]).expectedBodyFingerprint).toBe( - agentSessionSendBodyFingerprint(SESSION_ID, newer) - ) - expect(error).toHaveBeenCalledTimes(1) + expect(state.hook!.queued.editor).toBeUndefined() + expect(state.hook!.queued.cards[0]?.text).toBe('saved text') + expect(error).not.toHaveBeenCalled() expect(append).toHaveBeenCalledTimes(1) }) - it('hands unsaved typing to the composer and releases the hold when changing chats', async () => { + it('preserves typing made after an unknown result when delayed proof arrives', async () => { + const message = queuedDraft({ messageId: 'late-proof' }) + await mount(message) + sendRequest.mockImplementation(async (method, params) => { + if (method === 'agentSession.queuedMessageUpdate') { + throw markRpcDeliveryUnknown(new Error('lost')) + } + return readOrHoldReply(method, params) + }) + act(() => state.hook!.queued.editor!.change('submitted')) + await act(async () => { + await state.hook!.queued.editor!.save() + }) + act(() => state.hook!.queued.editor!.change('submitted plus unsaved')) + act(() => + state.listener?.(batchEvent([{ ...message, body: withText(message.body, 'submitted') }])) + ) + expect(state.hook!.queued.editor).toMatchObject({ + text: 'submitted plus unsaved', + saving: false + }) + expect(append).not.toHaveBeenCalled() + expect(resolved).not.toHaveBeenCalled() + }) + + it.each(['updated', 'not-editable'] as const)( + 'retains dictation during a deferred %s Save and after success', + async (status) => { + const message = queuedDraft({ messageId: 'dictation-save' }) + await mount(message) + act(() => state.hook!.queued.editor!.cancel()) + await act(async () => + state.renderer?.update(createElement(Harness, { composerScopeKey: 'original-bottom' })) + ) + await act(async () => { + await state.hook!.queued.edit!(message.messageId) + }) + let reply: ((response: RpcResponse) => void) | undefined + sendRequest.mockImplementation((method, params) => + method === 'agentSession.queuedMessageUpdate' + ? new Promise((resolve) => { + reply = resolve + }) + : Promise.resolve(readOrHoldReply(method, params)) + ) + act(() => state.hook!.queued.editor!.change('submitted')) + const insert = state.hook!.queued.editor!.insertDictation! + let saving: Promise | undefined + act(() => { + saving = state.hook!.queued.editor!.save() + }) + act(() => { + expect(insert('speech')).toBe(true) + }) + await act(async () => { + reply!(mutationOk({ status, messageId: message.messageId })) + await saving + }) + if (status === 'updated') { + expect(state.bottomText).toBe('') + expect(state.hook!.queued.editor!.text).toBe('submitted speech') + act(() => { + expect(insert('later')).toBe(true) + }) + expect(state.bottomText).toBe('') + expect(state.hook!.queued.editor!.text).toBe('submitted speech later') + } else { + expect(state.hook!.queued.editor!.text).toBe('submitted speech') + } + } + ) + + it('preserves unsaved typing once and releases the hold when changing chats', async () => { await mount(queuedDraft({ messageId: 'draft-1' })) - act(() => hook!.queued.editor!.change('unsaved')) - await act(async () => renderer?.update(createElement(Harness, { sessionId: 'other-chat' }))) + act(() => state.hook!.queued.editor!.change('unsaved')) + await act(async () => + state.renderer?.update(createElement(Harness, { sessionId: 'other-chat' })) + ) expect(append).toHaveBeenCalledExactlyOnceWith('unsaved') - expect(hook!.queued.editor).toBeUndefined() + expect(notice).toHaveBeenCalledOnce() + expect(state.hook!.queued.editor).toBeUndefined() expect( sendRequest.mock.calls .filter(([method]) => method === 'agentSession.queuedMessageEditHold') @@ -259,7 +341,7 @@ describe('mobile in-place queued Edit', () => { }) it.each(['updated', 'changed'] as const)( - 'ignores a deferred %s reply after leaving the chat', + 'lets the submitted snapshot own a deferred %s reply after leaving the chat', async (status) => { const message = queuedDraft({ messageId: 'deferred-card' }) await mount(message) @@ -271,30 +353,42 @@ describe('mobile in-place queued Edit', () => { }) : Promise.resolve(readOrHoldReply(method, params)) ) - act(() => hook!.queued.editor!.change('edited in place')) + act(() => state.hook!.queued.editor!.change('edited in place')) let save: Promise | undefined act(() => { - save = hook!.queued.editor!.save() + save = state.hook!.queued.editor!.save() }) - expect(hook!.queued.editor!.saving).toBe(true) - await act(async () => renderer?.update(createElement(Harness, { sessionId: 'other-chat' }))) - expect(append).toHaveBeenCalledExactlyOnceWith('edited in place') + expect(state.hook!.queued.editor!.saving).toBe(true) + await act(async () => + state.renderer?.update(createElement(Harness, { sessionId: 'other-chat' })) + ) + expect(append).not.toHaveBeenCalled() await act(async () => { - answer?.(mutationOk({ status, messageId: message.messageId, fingerprint: 'new' })) + answer?.( + mutationOk({ + status, + messageId: message.messageId, + fingerprint: requestedFingerprint(message) + }) + ) await save }) - expect(append).toHaveBeenCalledTimes(1) + if (status === 'changed') { + expect(append).toHaveBeenCalledExactlyOnceWith('edited in place') + expect(notice).toHaveBeenCalledOnce() + } else { + expect(append).not.toHaveBeenCalled() + expect(notice).not.toHaveBeenCalled() + } expect(error).not.toHaveBeenCalled() expect(resolved).not.toHaveBeenCalled() - expect(hook!.queued.editor).toBeUndefined() + expect(state.hook!.queued.editor).toBeUndefined() } ) - it('lets an in-flight Save decide a removed card: its refusal hands the typing to the composer', async () => { + it('leaves a disappeared card to its in-flight Save and returns a refused submitted draft once', async () => { const message = queuedDraft({ messageId: 'lost-reply-card' }) - const nextMessage = queuedDraft({ messageId: 'next-card', position: 2 }) await mount(message) - act(() => listener?.(batchEvent([message, nextMessage]))) let answer: ((response: RpcResponse) => void) | undefined sendRequest.mockImplementation((method, params) => method === 'agentSession.queuedMessageUpdate' @@ -303,13 +397,14 @@ describe('mobile in-place queued Edit', () => { }) : Promise.resolve(readOrHoldReply(method, params)) ) - act(() => hook!.queued.editor!.change('submitted edit')) + act(() => state.hook!.queued.editor!.change('submitted edit')) let save: Promise | undefined act(() => { - save = hook!.queued.editor!.save() + save = state.hook!.queued.editor!.save() }) - act(() => listener?.(batchEvent([nextMessage]))) - expect(hook!.queued.editor).toMatchObject({ messageId: message.messageId, saving: true }) + act(() => state.listener?.(batchEvent([]))) + expect(state.hook!.queued.editor?.saving).toBe(true) + expect(append).not.toHaveBeenCalled() await act(async () => { answer?.( ok({ @@ -319,15 +414,12 @@ describe('mobile in-place queued Edit', () => { ) expect(await save).toBe(false) }) - expect(hook!.queued.editor).toBeUndefined() + expect(state.hook!.queued.editor).toBeUndefined() expect(append).toHaveBeenCalledExactlyOnceWith('submitted edit') - expect(error).toHaveBeenCalledExactlyOnceWith( - 'This message was already sent or removed. Your edit is in the chat box.' - ) - expect(resolved).not.toHaveBeenCalled() + expect(notice).toHaveBeenCalledOnce() }) - it('keeps a refused Save in the editor and hands it to the composer once the card is gone', async () => { + it('keeps a refused Save in the editor and closes only when the published card is gone', async () => { const message = queuedDraft({ messageId: 'refused-card' }) await mount(message) sendRequest.mockImplementation(async (method, params) => @@ -338,34 +430,30 @@ describe('mobile in-place queued Edit', () => { }) : readOrHoldReply(method, params) ) - act(() => hook!.queued.editor!.change('keep this text')) + act(() => state.hook!.queued.editor!.change('keep this text')) await act(async () => { - expect(await hook!.queued.editor!.save()).toBe(false) + expect(await state.hook!.queued.editor!.save()).toBe(false) }) - expect(hook!.queued.editor?.text).toBe('keep this text') - expect(hook!.queued.editor?.saving).toBe(false) + expect(state.hook!.queued.editor?.text).toBe('keep this text') + expect(state.hook!.queued.editor?.saving).toBe(false) expect(error).toHaveBeenCalledTimes(1) expect(append).not.toHaveBeenCalled() - act(() => listener?.(batchEvent([]))) - expect(hook!.queued.editor).toBeUndefined() - expect(append).toHaveBeenCalledExactlyOnceWith('keep this text') - expect(error).toHaveBeenLastCalledWith( - 'This message was already sent or removed. Your edit is in the chat box.' - ) + act(() => state.listener?.(batchEvent([]))) + expect(state.hook!.queued.editor).toBeUndefined() }) it('keeps exact typing through disconnect and reacquires the hold on the replacement transport', async () => { vi.useFakeTimers() await mount(queuedDraft({ messageId: 'reconnect-card' })) const text = 'unsaved text\nwith spacing and 🐈' - act(() => hook!.queued.editor!.change(text)) + act(() => state.hook!.queued.editor!.change(text)) await act(async () => - renderer?.update(createElement(Harness, { connected: false, transport: null })) + state.renderer?.update(createElement(Harness, { connected: false, transport: null })) ) await act(async () => { await vi.advanceTimersByTimeAsync(30_000) }) - expect(hook!.queued.editor?.text).toBe(text) + expect(state.hook!.queued.editor?.text).toBe(text) const reconnectedRequest = vi.fn(async (method, params) => method === 'agentSession.queuedMessageEditHold' ? fieldsOf(params).action === 'renew' @@ -374,14 +462,14 @@ describe('mobile in-place queued Edit', () => { : ok({ models: [], current: {} }) ) await act(async () => - renderer?.update( + state.renderer?.update( createElement(Harness, { transport: { ...client, sendRequest: reconnectedRequest } }) ) ) await act(async () => { await vi.advanceTimersByTimeAsync(30_000) }) - expect(hook!.queued.editor?.text).toBe(text) + expect(state.hook!.queued.editor?.text).toBe(text) expect( reconnectedRequest.mock.calls .filter(([method]) => method === 'agentSession.queuedMessageEditHold') @@ -389,23 +477,90 @@ describe('mobile in-place queued Edit', () => { ).toEqual(['renew', 'acquire']) expect(error).not.toHaveBeenCalled() expect(append).not.toHaveBeenCalled() - await act(async () => hook!.queued.editor!.cancel()) + await act(async () => state.hook!.queued.editor!.cancel()) await act(async () => - renderer?.update(createElement(Harness, { connected: false, transport: null })) + state.renderer?.update(createElement(Harness, { connected: false, transport: null })) ) const calls = reconnectedRequest.mock.calls.length await act(async () => { - expect(await hook!.queued.edit!('reconnect-card')).toBe(false) + expect(await state.hook!.queued.edit!('reconnect-card')).toBe(false) }) expect(error).toHaveBeenCalledExactlyOnceWith('Reconnect to edit.') - expect(hook!.queued.editor).toBeUndefined() + expect(state.hook!.queued.editor).toBeUndefined() expect(reconnectedRequest).toHaveBeenCalledTimes(calls) }) + it('returns skills, new images, files and pending work into the original non-empty draft on involuntary close', async () => { + const message = queuedDraft({ + messageId: 'full-draft', + body: { + kind: 'message', + role: 'user', + blocks: [ + { type: 'text', text: '$review original' }, + { type: 'image-ref', path: '/original.png' } + ], + skills: [{ name: 'review', path: '/remote/SKILL.md' }] + } + }) + await mount(message) + act(() => state.hook!.queued.editor!.cancel()) + await act(async () => + state.renderer?.update( + createElement(Harness, { + composerScopeKey: 'original-bottom', + imagesSupport: true, + skillsSupport: true + }) + ) + ) + await act(async () => { + await state.hook!.queued.edit!(message.messageId) + }) + const scope = state.hook!.queued.editor!.scopeKey + const operation = mobileNativeChatDraftDestination(scope) + act(() => { + setMobileNativeChatDraftText('original-bottom', 'already typed') + state.hook!.queued.editor!.change('$review changed') + updateMobileNativeChatDraftInputs(scope, (current) => ({ + ...current, + pending: 1, + images: [ + ...current.images, + { id: 'new-image', path: '/new.png', previewUri: 'file:///new.png' } + ], + files: [{ id: 'file', name: 'report.txt', path: null, mime: 'text/plain', byteLength: 10 }] + })) + }) + expect(state.hook!.queued.editor!.canSave).toBe(false) + act(() => state.listener?.(batchEvent([]))) + expect(state.hook!.queued.editor).toBeUndefined() + expect(state.bottomText).toBe('already typed\n\n$review changed') + expect(readMobileNativeChatDraftInputs('original-bottom')).toMatchObject({ + skills: message.body.skills, + images: [{ id: 'new-image' }], + files: [{ id: 'file', path: null }], + pending: 1 + }) + act(() => + updateMobileNativeChatDraftInputs(operation, (current) => ({ + ...current, + pending: 0, + files: current.files.map((file) => ({ ...file, path: '/stored/report.txt' })) + })) + ) + expect(readMobileNativeChatDraftInputs('original-bottom').files[0]?.path).toBe( + '/stored/report.txt' + ) + expect(readMobileNativeChatDraftInputs(scope).images).toEqual([]) + expect(notice).toHaveBeenCalledOnce() + expect(error).not.toHaveBeenCalled() + }) + it('submits Save while hold acquisition is still pending', async () => { const message = queuedDraft({ messageId: 'save-before-hold' }) await mount(message) - act(() => hook!.queued.editor!.cancel()) + act(() => state.hook!.queued.editor!.cancel()) let answerHold: ((response: RpcResponse) => void) | undefined sendRequest.mockImplementation((method, params) => method === 'agentSession.queuedMessageEditHold' && fieldsOf(params).action === 'acquire' @@ -415,17 +570,21 @@ describe('mobile in-place queued Edit', () => { : method === 'agentSession.queuedMessageEditHold' ? Promise.resolve(readOrHoldReply(method, params)) : Promise.resolve( - mutationOk({ status: 'updated', messageId: message.messageId, fingerprint: 'new' }) + mutationOk({ + status: 'updated', + messageId: message.messageId, + fingerprint: requestedFingerprint(message) + }) ) ) await act(async () => { - await hook!.queued.edit!(message.messageId) + await state.hook!.queued.edit!(message.messageId) }) - act(() => hook!.queued.editor!.change('saved without hold reply')) + act(() => state.hook!.queued.editor!.change('saved without hold reply')) await act(async () => { - expect(await hook!.queued.editor!.save()).toBe(true) + expect(await state.hook!.queued.editor!.save()).toBe(true) }) - expect(hook!.queued.editor).toBeUndefined() + expect(state.hook!.queued.editor).toBeUndefined() expect(answerHold).toBeDefined() await act(async () => answerHold?.(editHoldReply({ action: 'acquire' }))) expect(append).not.toHaveBeenCalled() diff --git a/mobile/src/session/use-mobile-structured-queued-edit.ts b/mobile/src/session/use-mobile-structured-queued-edit.ts index 284608180d8f..3366bf10f2f2 100644 --- a/mobile/src/session/use-mobile-structured-queued-edit.ts +++ b/mobile/src/session/use-mobile-structured-queued-edit.ts @@ -1,293 +1,303 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' -import type { - AgentSessionQueuedMessage, - AgentSessionQueuedMessageUpdateResult -} from '../../../src/shared/agent-session-wire' +import type { QueuedEditLeaseLoss } from '../../../src/shared/queued-message-edit-lease' +import { createMobileQueuedEdit } from './mobile-queued-message-edit-open' +import { + useCallback, + useEffect, + useEffectEvent, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' +import type { AgentSessionQueuedMessageUpdateResult } from '../../../src/shared/agent-session-wire' import { agentSessionSendBodyFingerprint } from '../../../src/shared/structured-agent-session-send-mutation' +import { queuedMessageWithEdit } from '../../../src/shared/queued-message-edit' import { - QUEUED_EDIT_NOTICE_COPY, - queuedCardOnScreen, - queuedEditChangedNotice, - queuedEditDirty, - queuedEditHandOffTyping, - queuedEditKeptImages, - queuedEditStanding, - queuedMessageEditableText, - queuedMessageWithEdit, - type QueuedMessageEditDraft -} from '../../../src/shared/queued-message-edit' -import type { RpcClient } from '../transport/rpc-client' + mobileQueuedEditValueKey, + snapshotMobileQueuedEditInputs +} from './mobile-queued-message-edit-value' +import { settleMobileQueuedEdit } from './mobile-queued-message-edit-outcome' +import { queuedEditPublicationKey } from '../../../src/shared/queued-message-edit-publication' +import { startMobileQueuedEditLease } from './mobile-queued-message-edit-lease' import { - startMobileQueuedEditLease, - type MobileQueuedEditLease -} from './mobile-queued-message-edit-lease' -import type { MobileStructuredAgentMutate } from './use-mobile-structured-agent-mutation' -import type { MobileQueuedMessageCard } from './mobile-structured-queued-message-cards' - -export type MobileQueuedMessageInlineEditor = { - messageId: string - text: string - saving: boolean - canSave: boolean - change: (text: string) => void - save: () => Promise - cancel: () => void -} - -// The phone edits text only, so the card's images are all kept. -type Edit = QueuedMessageEditDraft & { - scope: string - messageId: string - body: AgentSessionQueuedMessage['body'] - fingerprint: string - saving: boolean - lease: MobileQueuedEditLease -} + useMobileNativeChatDraftInputs, + readMobileNativeChatDraftInputs, + mobileNativeChatDraftDestination +} from './native-chat/composer/mobile-native-chat-draft-inputs' +import { + closeMobileQueuedEditDraft, + mobileQueuedEditSaving, + insertMobileQueuedEditDictation, + serializeMobileQueuedEdit, + mobileQueuedEditSaveHeld, + removeMobileQueuedEditImage, + type MobileQueuedEditDraft as Edit, + type MobileStructuredQueuedEditArgs, + type MobileQueuedMessageInlineEditor +} from './mobile-queued-message-edit-draft' +export type { MobileQueuedMessageInlineEditor } from './mobile-queued-message-edit-draft' -export function useMobileStructuredQueuedEdit(args: { - sessionKey: string - sessionId: string | null - client: RpcClient | null - chatActive: boolean - enabled: boolean - messages: readonly AgentSessionQueuedMessage[] | null - cards: readonly MobileQueuedMessageCard[] - submissions: readonly AgentJournalSubmission[] - mutate: MobileStructuredAgentMutate - appendComposerText: ((text: string) => boolean) | undefined - onSendError: (text: string) => void - onActionResolved?: () => void -}) { - const { sessionKey, sessionId, client, enabled, mutate, onSendError, onActionResolved } = args - const { messages, cards, submissions, appendComposerText } = args - const clientRef = useRef({ sessionKey, client }) - const latest = useRef({ messages, cards, submissions, appendComposerText }) - useLayoutEffect(() => { - clientRef.current = { sessionKey, client } - latest.current = { messages, cards, submissions, appendComposerText } - }, [appendComposerText, cards, client, messages, sessionKey, submissions]) +export function useMobileStructuredQueuedEdit(args: MobileStructuredQueuedEditArgs) { + const { sessionKey, sessionId, client, mutate, onSendError, onActionResolved } = args const active = useRef(null) - // A changed reply reopens the editor once the card publishes its newer text. - const reopen = useRef<{ scope: string; messageId: string; stale: string } | null>(null) const [shown, setShown] = useState(null) + const latest = useRef(args) + useLayoutEffect(() => { + latest.current = args + const edit = active.current + if (edit?.scope === args.sessionKey) { + edit.published = { + messages: args.messages, + submissions: args.submissions ?? [], + cards: args.cards + } + } + }) + useMobileNativeChatDraftInputs(shown?.scopeKey ?? null) const render = useCallback((edit: Edit | null) => { active.current = edit setShown(edit ? { ...edit } : null) }, []) - const close = useCallback(() => { - const edit = active.current - render(null) - edit?.lease.end() - }, [render]) - /** Unsaved typing goes to the chat box; false when there was none to keep. */ - const handOff = useCallback((edit: Edit): boolean => { - const typing = queuedEditHandOffTyping(edit.body, edit) - return typing !== '' && latest.current.appendComposerText?.(typing) === true - }, []) - const gone = useCallback( - (edit: Edit) => { - if (active.current !== edit) { + const close = useCallback( + (edit: Edit, handBack = false) => { + closeMobileQueuedEditDraft(edit, handBack, latest.current.onQueuedEditNotice) + if (active.current === edit) { + render(null) + } + }, + [render] + ) + + const receiveLoss = useCallback( + (edit: Edit, loss: QueuedEditLeaseLoss, observe: (edit: Edit) => unknown) => { + if (edit.closed || edit.lease !== loss.lease) { return } - if (queuedEditDirty(edit.body, edit) && handOff(edit)) { - onSendError(QUEUED_EDIT_NOTICE_COPY.editGone) + edit.leaseLoss = loss + observe(edit) + }, + [] + ) + const acquire = useCallback( + (edit: Edit, observe: (edit: Edit) => unknown) => { + edit.lease.end() + edit.leaseLoss = undefined + edit.lease = startMobileQueuedEditLease({ + getClient: () => + latest.current.sessionKey === edit.scope ? latest.current.client : edit.client, + sessionId: edit.sessionId, + messageId: edit.messageId, + expectedBodyFingerprint: edit.fingerprint, + onLost: (loss) => receiveLoss(edit, loss, observe) + }) + }, + [receiveLoss] + ) + + const settle = useCallback( + function settleResult( + edit: Edit, + attempt = edit.attempt, + result?: Awaited>> + ) { + if (edit.closed || edit.attempt !== attempt || (!result && mobileQueuedEditSaving(edit))) { + return false + } + const outcome = settleMobileQueuedEdit(edit, attempt, result) + if (outcome === 'close') { + close(edit, true) + if (!edit.pendingClose) { + onActionResolved?.() + } + return true } - close() + if (outcome === 'rebase') { + acquire(edit, settleResult) + } else if (outcome === 'gone' || outcome === 'changed' || edit.pendingClose) { + // A restored seed is still intent if an earlier write remains uncertain or a rival changed it. + edit.uncertain ||= outcome === 'changed' + close(edit, true) + return false + } else if (outcome === 'refused' && result?.status === 'accepted') { + onSendError("This message can't be edited.") + } + if (active.current === edit) { + render(edit) + } + return false }, - [close, handOff, onSendError] + [acquire, close, onActionResolved, onSendError, render] ) - // On chat changes or unmount, hand unsaved typing back to the chat it came from: a layout - // cleanup runs before the composer's append is repointed at the next chat. - useLayoutEffect(() => { - return () => { + useEffect( + () => () => { const edit = active.current - active.current = null - reopen.current = null - setShown(null) - if (edit && queuedEditDirty(edit.body, edit)) { - handOff(edit) + if (!edit) { + return } - edit?.lease.end() - } - }, [handOff, sessionKey, args.chatActive]) + // A submitted snapshot owns its reply even across navigation; no publication duplicates it. + if (mobileQueuedEditSaving(edit)) { + edit.pendingClose = true + active.current = null + return + } + close(edit, true) + }, + [sessionKey, args.chatActive, close] + ) const begin = useCallback( async (messageId: string): Promise => { - reopen.current = null - if (active.current || !enabled || !client || !sessionId) { - return false - } - const message = messages?.find((entry) => entry.messageId === messageId) - const text = message ? queuedMessageEditableText(message.body) : null - if (!message || text === null) { + if (active.current) { return false } - const fingerprint = agentSessionSendBodyFingerprint(sessionId, message.body) - const edit: Edit = { - scope: sessionKey, + const edit = createMobileQueuedEdit( + { ...latest.current, sessionKey, sessionId, client }, messageId, - body: message.body, - text, - kept: queuedEditKeptImages(message.body), - added: [], - fingerprint, - saving: false, - lease: startMobileQueuedEditLease({ - getClient: () => - clientRef.current.sessionKey === sessionKey ? clientRef.current.client : client, - sessionId, - messageId, - expectedBodyFingerprint: fingerprint - }) + () => (latest.current.sessionKey === sessionKey ? latest.current.client : client), + (edit, loss) => receiveLoss(edit, loss, settle) + ) + if (!edit) { + return false } render(edit) - void edit.lease.acquired.then( - (answer) => { - if (!isOpen(active, edit) || edit.saving) { - return - } - // Sent, removed or edited elsewhere before the edit began: the card shows what happened. - if (answer.status === 'gone' || answer.status === 'changed') { - gone(edit) - } else if (answer.status === 'not-editable') { - handOff(edit) - onSendError(QUEUED_EDIT_NOTICE_COPY.editFailed) - close() - } - }, - () => undefined - ) return true }, - [client, close, enabled, gone, handOff, messages, onSendError, render, sessionId, sessionKey] - ) - const change = useCallback( - (text: string) => { - const edit = active.current - if (edit && !edit.saving) { - edit.text = text - render(edit) - } - }, - [render] + [client, receiveLoss, render, sessionId, sessionKey, settle] ) + const save = useCallback(async (): Promise => { const edit = active.current - if (!edit || edit.saving || !sessionId) { + if (!edit || mobileQueuedEditSaving(edit) || !sessionId) { return false } - const edited = queuedMessageWithEdit(edit.body, { text: edit.text }) - if (!edited.ok) { + if (mobileQueuedEditSaveHeld(edit, args.imagesCapable)) { return false } - if (!queuedEditDirty(edit.body, edit)) { - close() - return true - } - // When Save’s reply is lost or refused, published card state proves whether its body landed. - const desired = agentSessionSendBodyFingerprint(sessionId, edited.body) - const standing = () => queuedEditStanding(latest.current, sessionId, edit.messageId, desired) - edit.saving = true - render(edit) - const result = await mutate( - 'agentSession.queuedMessageUpdate', - 'agentSession.queuedMessageUpdate', - { messageId: edit.messageId, expectedBodyFingerprint: edit.fingerprint, text: edit.text }, - () => isOpen(active, edit) && standing() === 'differs' - ) - // A reply during a chat switch belongs to the outgoing chat. - if (!isOpen(active, edit) || clientRef.current.sessionKey !== edit.scope) { - return false - } - edit.saving = false - if (result.status === 'accepted') { - const { status } = result.value - if (status === 'updated' || status === 'unchanged') { - close() - onActionResolved?.() - return true - } - if (status === 'gone') { - gone(edit) - return false - } - if (status === 'changed') { - // Preserve typing after a competing edit, then reopen on the newer text. - onSendError(QUEUED_EDIT_NOTICE_COPY[queuedEditChangedNotice(handOff(edit), false)]) - reopen.current = { scope: edit.scope, messageId: edit.messageId, stale: edit.fingerprint } - close() - return false - } - onSendError(QUEUED_EDIT_NOTICE_COPY.editFailed) - render(edit) + const fields = serializeMobileQueuedEdit(edit, args.imagesCapable, args.skillsCapable) + const desired = queuedMessageWithEdit(edit.body, fields) + if (!desired.ok) { return false } - const settled = standing() - if (settled === 'sent' || settled === 'holds') { - close() + if ( + !edit.uncertain && + !edit.attempt && + mobileQueuedEditValueKey(edit.text, readMobileNativeChatDraftInputs(edit.scopeKey)) === + edit.baseKey + ) { + close(edit, true) onActionResolved?.() return true } - if (settled === 'gone') { - gone(edit) - return false + const attempt = { + phase: 'pending' as const, + expected: edit.fingerprint, + fingerprint: agentSessionSendBodyFingerprint(sessionId, desired.body), + body: desired.body, + text: edit.text, + inputs: snapshotMobileQueuedEditInputs(readMobileNativeChatDraftInputs(edit.scopeKey)), + key: mobileQueuedEditValueKey(edit.text, readMobileNativeChatDraftInputs(edit.scopeKey)) } + edit.attempt = attempt render(edit) - return false - }, [close, gone, handOff, mutate, onActionResolved, onSendError, render, sessionId]) + const result = await mutate( + 'agentSession.queuedMessageUpdate', + 'agentSession.queuedMessageUpdate', + { messageId: edit.messageId, expectedBodyFingerprint: attempt.expected, ...fields }, + () => !edit.closed && !edit.pendingClose && edit.leaseLoss?.lease !== edit.lease + ) + return settle(edit, attempt, result) + }, [ + args.imagesCapable, + args.skillsCapable, + close, + mutate, + onActionResolved, + render, + sessionId, + settle + ]) - // Let in-flight Save decide delivery races; otherwise reconcile departures, but never infer them from an unloaded list. - useEffect(() => { + const publication = useMemo( + () => + queuedEditPublicationKey( + { messages: args.messages, cards: args.cards, submissions: args.submissions }, + sessionId ?? '', + shown?.messageId + ), + [args.messages, args.cards, args.submissions, sessionId, shown?.messageId] + ) + const reconcile = useEffectEvent(() => { const edit = active.current - if (edit && edit.scope === sessionKey && !edit.saving && messages !== null && sessionId) { - const typed = queuedMessageWithEdit(edit.body, { text: edit.text }) - const desired = typed.ok ? agentSessionSendBodyFingerprint(sessionId, typed.body) : null - const standing = queuedEditStanding( - { messages, cards, submissions }, - sessionId, - edit.messageId, - desired - ) - // `holds`: the card already holds what is typed, as after a Save whose answer was lost. - if (standing === 'sent' || (standing === 'holds' && desired !== edit.fingerprint)) { - close() - } else if (standing === 'gone') { - gone(edit) - } - } - const pending = reopen.current - if (!pending || active.current || messages === null || !sessionId) { + if ( + !edit || + mobileQueuedEditSaving(edit) || + edit.scope !== sessionKey || + args.messages === null + ) { return } - const listed = messages.find((entry) => entry.messageId === pending.messageId) - if (pending.scope !== sessionKey || !listed || !queuedCardOnScreen(cards, pending.messageId)) { - reopen.current = null - } else if (agentSessionSendBodyFingerprint(sessionId, listed.body) !== pending.stale) { - void begin(pending.messageId) + const onScreen = args.cards.some(({ messageId }) => messageId === edit.messageId) + if (!onScreen && !edit.attempt) { + close(edit, true) + } else { + settle(edit) } - }, [begin, cards, close, gone, messages, sessionId, sessionKey, shown, submissions]) + }) + useEffect(() => { + reconcile() + }, [publication, sessionKey]) + const editing = active.current + const inputDestination = editing ? mobileNativeChatDraftDestination(editing.scopeKey) : null const editor: MobileQueuedMessageInlineEditor | undefined = shown?.scope === sessionKey ? { messageId: shown.messageId, + scopeKey: shown.scopeKey, + client: shown.client, + sessionId: shown.sessionId, + imagesCapable: args.imagesCapable === true, text: shown.text, - saving: shown.saving, - canSave: queuedMessageWithEdit(shown.body, { text: shown.text }).ok, - change, + saving: mobileQueuedEditSaving(shown), + canSave: + !mobileQueuedEditSaveHeld(shown, args.imagesCapable) && + queuedMessageWithEdit( + shown.body, + serializeMobileQueuedEdit(shown, args.imagesCapable, args.skillsCapable) + ).ok, + insertDictation: (text) => { + const inserted = + editing && + inputDestination && + insertMobileQueuedEditDictation(editing, inputDestination, text) + if (inserted && active.current === editing) { + render(editing) + } + return Boolean(inserted) + }, + change: (text) => { + const edit = active.current + if (edit && !mobileQueuedEditSaving(edit)) { + edit.text = text + render(edit) + } + }, + removeImage: (id) => { + const edit = active.current + if (!edit || mobileQueuedEditSaving(edit) || !args.imagesCapable) { + return + } + removeMobileQueuedEditImage(edit, id) + render(edit) + }, save, cancel: () => { - if (!active.current?.saving) { - close() + if (active.current) { + close(active.current) } } } : undefined return { begin, editor } } - -function isOpen(active: { current: Edit | null }, edit: Edit): boolean { - return active.current === edit -} diff --git a/mobile/src/session/use-mobile-structured-queued-message-controls.ts b/mobile/src/session/use-mobile-structured-queued-message-controls.ts index f5d46e5e8adc..2d6694801d3a 100644 --- a/mobile/src/session/use-mobile-structured-queued-message-controls.ts +++ b/mobile/src/session/use-mobile-structured-queued-message-controls.ts @@ -53,6 +53,9 @@ export function useMobileStructuredQueuedMessageControls(args: { chatActive: boolean enabled: boolean editCapable: boolean | null + imagesCapable?: boolean + skillsCapable?: boolean + composerScopeKey?: string | null reorderCapable?: boolean agentName?: string journalItems?: readonly AgentJournalRenderItem[] @@ -63,6 +66,7 @@ export function useMobileStructuredQueuedMessageControls(args: { mutate: MobileStructuredAgentMutate // Without a live composer or a successful copy, Edit must refuse. appendComposerText: ((text: string) => boolean) | undefined + onQueuedEditNotice?: (message: string) => void onSendError: (message: string) => void onActionResolved?: () => void }): MobileStructuredQueuedMessageControls { diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.ts b/mobile/src/transport/mobile-runtime-client-capabilities.ts index a8e37d9e33fa..92e0953a9fc9 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.ts @@ -1,3 +1,4 @@ +import { AGENT_SESSION_QUEUED_IMAGE_CARDS_CLIENT_CAPABILITY } from '../../../src/shared/agent-session-runtime-capabilities' import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from '../../../src/shared/structured-agent-session-surface-capabilities' import { AGENT_SESSION_CURRENT_REQUESTS_CLIENT_CAPABILITY } from '../../../src/shared/agent-session-current-requests-capability' import { @@ -44,7 +45,8 @@ export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilitie AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY, // Native chat offers no Stop for a turn the host restored but could not confirm. AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY, - AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_IMAGE_CARDS_CLIENT_CAPABILITY ]) export const MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD = diff --git a/src/main/acp/acp-structured-options.test.ts b/src/main/acp/acp-structured-options.test.ts index 3b2798901970..2d848e08a680 100644 --- a/src/main/acp/acp-structured-options.test.ts +++ b/src/main/acp/acp-structured-options.test.ts @@ -5,6 +5,9 @@ import { grokModelEfforts } from './acp-dialects/grok-model-catalog' import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' import { OMP_ACP_DIALECT } from './acp-dialects/omp-dialect' import { OPENCODE_ACP_DIALECT } from './acp-dialects/opencode-dialect' +import type { AcpCommandOptionsFixtureModule } from '../../shared/mobile-queued-edit-host.test-fixture-contract' + +const mobileProducer = { AcpStructuredOptions } satisfies AcpCommandOptionsFixtureModule const configOptions = [ { @@ -36,7 +39,7 @@ describe('ACP session options', () => { 'projects %i live models once while retaining current/report values', async (count) => { const modelEfforts = vi.fn(grokModelEfforts) - const options = new AcpStructuredOptions({ ...GROK_ACP_DIALECT, modelEfforts }) + const options = new mobileProducer.AcpStructuredOptions({ ...GROK_ACP_DIALECT, modelEfforts }) options.adoptSession({ configOptions: [ ...configOptions.slice(1), diff --git a/src/main/codex/codex-structured-skill-report.test.ts b/src/main/codex/codex-structured-skill-report.test.ts index bb5461028da1..18e105171e88 100644 --- a/src/main/codex/codex-structured-skill-report.test.ts +++ b/src/main/codex/codex-structured-skill-report.test.ts @@ -117,7 +117,8 @@ describe('a running Codex chat lists and runs the skills Codex loaded', () => { }), [] ], - [retyped, []] + [retyped, []], + [edit(body, { text: '/review', skills: [{ name: 'review', path: REPO_REVIEW }] }), []] ] as const) { expect(edited).toMatchObject(retainedFields) const adapter = await acquired(codex) diff --git a/src/main/native-chat/agent-session-journal/queued-edit-host.test-fixture.ts b/src/main/native-chat/agent-session-journal/queued-edit-host.test-fixture.ts new file mode 100644 index 000000000000..2f559cbdc4a4 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-edit-host.test-fixture.ts @@ -0,0 +1,59 @@ +import { QueuedMessageEditLeases } from './queued-message-edit-leases' +import { + queuedMessageUpdateDecision as decideUpdate, + type QueuedMessageUpdate +} from './queued-message-update' +import type { QueuedMessageRow } from './queued-message-table' +import type { QueuedEditHostFixtureModule } from '../../../shared/queued-edit-host.test-fixture' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' + +/** Real CAS/lease decisions; delivery of their answers is controlled by the client harness. */ +export const queuedEditHost: QueuedEditHostFixtureModule['queuedEditHost'] = ( + sessionId, + message +) => { + let row: QueuedMessageRow | null = { + ...message, + fingerprint: agentSessionSendBodyFingerprint(sessionId, message.body), + holdReason: null, + returnedReason: null, + returnedRejection: null, + consumedAs: null, + queuedAt: null + } + const leases = new QueuedMessageEditLeases(() => (row ? [row] : [])) + const update = (input: QueuedMessageUpdate) => { + const result = decideUpdate(row, sessionId, input) + if (result.status !== 'ready') { + return result + } + if (!row) { + throw new Error('missing row') + } + row = { ...row, body: result.body, fingerprint: result.fingerprint } + leases.retire(message.messageId) + return { + status: 'updated' as const, + messageId: message.messageId, + fingerprint: result.fingerprint + } + } + return { + leases, + update, + get row() { + return row + }, + depart: () => { + row = null + }, + acquire: (editId: string, expected: string) => + leases.acquire( + { callerKey: 'client', editId }, + { messageId: message.messageId, fingerprint: expected } + ) + } +} + +export const queuedMessageUpdateDecision: QueuedEditHostFixtureModule['queuedMessageUpdateDecision'] = + decideUpdate diff --git a/src/main/native-chat/agent-session-journal/queued-message-update.spec.ts b/src/main/native-chat/agent-session-journal/queued-message-update.spec.ts index c9392b569cff..fb224cce703a 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-update.spec.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-update.spec.ts @@ -35,7 +35,7 @@ export function registerQueuedMessageUpdateTests(): void { } describe('queued text update decision', () => { - it('keeps sender, send mode and attachment order, replacing only the text', () => { + it('attributes changed content to the user while keeping send mode and attachment order', () => { const body: AgentJournalMessageItem = { ...initial, sentAs: 'goal', @@ -48,13 +48,15 @@ export function registerQueuedMessageUpdateTests(): void { ] } const decision = decide(card(body), 'edited') + const { from: _sender, ...plain } = body expect(decision).toMatchObject({ status: 'ready', body: { - ...body, + ...plain, blocks: [body.blocks[0]!, { type: 'text', text: 'edited' }, body.blocks[2]!] } }) + expect(decision).not.toHaveProperty('body.from') }) it('checks the resulting body before the expected base, making lost-answer retry safe', () => { const edited = card({ ...initial, blocks: [{ type: 'text', text: 'edit' }] }) diff --git a/src/main/native-chat/agent-session-journal/queued-message-update.ts b/src/main/native-chat/agent-session-journal/queued-message-update.ts index 7741bb343d70..5eacc8d814a0 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-update.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-update.ts @@ -9,7 +9,7 @@ export type QueuedMessageUpdate = QueuedMessageEdit & { expectedBodyFingerprint: string } -/** Check the resulting body before CAS so lost Save replies retry safely without overwriting newer edits. */ +/** A user edit: reconcile lost replies before CAS, then durably attribute changed content to the user. */ export function queuedMessageUpdateDecision( row: QueuedMessageRow | null, sessionId: string, @@ -40,5 +40,6 @@ export function queuedMessageUpdateDecision( if (row.fingerprint !== input.expectedBodyFingerprint) { return { status: 'changed', messageId } } - return { status: 'ready', body: edited.body, previous: row.body, fingerprint } + const { from: _sender, ...body } = edited.body + return { status: 'ready', body, previous: row.body, fingerprint } } diff --git a/src/main/native-chat/agent-session-wire/mobile-queued-edit-host.test-fixture.ts b/src/main/native-chat/agent-session-wire/mobile-queued-edit-host.test-fixture.ts new file mode 100644 index 000000000000..1233b7ce2c8c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/mobile-queued-edit-host.test-fixture.ts @@ -0,0 +1,56 @@ +import type { MobileQueuedEditHostFixtureModule } from '../../../shared/mobile-queued-edit-host.test-fixture-contract' +import { + QueuedMessageEditHoldParams, + QueuedMessageUpdateParams +} from '../../../shared/rpc-contract/structured-agent-session-queued-edit-params' +import { + createQueuedMessageTestRig, + QUEUED_RIG_CALLER +} from './structured-agent-session-queued-message-rig.test-fixture' +import { HOST_TEST_NOW, HOST_TEST_SESSION } from './structured-agent-session-host-test-data' + +export { HOST_TEST_NOW as now } + +// Keep the real host checked with Node globals; the phone compiler checks the fixture contract. +export const createMobileQueuedEditHostFixture: MobileQueuedEditHostFixtureModule['createMobileQueuedEditHostFixture'] = + async () => { + const rig = await createQueuedMessageTestRig() + const journal = () => rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)!.journal + return { + sessionId: HOST_TEST_SESSION, + start: rig.workingSend, + insert: (input) => journal().queuedMessages.insert(input), + read: (messageId) => journal().queuedMessages.get(messageId), + published: async (messageId) => { + const result = await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + const row = result.ok + ? result.page.queuedMessages?.find((entry) => entry.messageId === messageId) + : undefined + if (!row) { + throw new Error('missing published queued message') + } + return row + }, + request: async (method, params) => { + if (method === 'agentSession.queuedMessageEditHold') { + return rig.host.queuedMessageEditHold( + QUEUED_RIG_CALLER, + QueuedMessageEditHoldParams.parse(params) + ) + } + if (method !== 'agentSession.queuedMessageUpdate') { + throw new Error('unexpected host fixture request') + } + const { envelope, ...fields } = QueuedMessageUpdateParams.parse(params) + return rig.host.queuedMessageUpdate(QUEUED_RIG_CALLER, { + ...fields, + envelope: rig.envelope(fields, method, envelope.clientOperationId) + }) + }, + restart: async () => { + await rig.restartHostProcess() + await rig.drafts() + }, + dispose: rig.dispose + } + } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts index 3c55c94bd45c..f38583ad9d31 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts @@ -1,6 +1,8 @@ +import { startQueuedEditLease } from '../../../shared/queued-message-edit-lease' +import { acquired, fakeCodex } from '../../codex/codex-structured-session-adapter-fixture' import { mkdir, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' import { createQueuedMessageTestRig, eventually, @@ -21,6 +23,7 @@ import { shouldQueueStructuredAgentSessionSend } from './structured-agent-sessio import { QUEUED_MESSAGES_PUBLISHED_MAX_BYTES } from './structured-agent-session-queued-published-bytes' import { QUEUED_MESSAGE_EDIT_LEASE_MS } from '../agent-session-journal/queued-message-edit-leases' import { agentSessionAttachmentStoreRoot } from '../agent-session-attachments/agent-session-attachment-references' +import type { AgentJournalSkillRef } from '../../../shared/agent-session-journal-types' import type { QueuedMessageImageBlock } from '../../../shared/queued-message-edit' let rig: QueuedMessageTestRig @@ -56,13 +59,15 @@ function update( text: string, operationId?: string, images?: QueuedMessageImageBlock[], - queuedImageCards?: boolean + queuedImageCards?: boolean, + skills?: readonly AgentJournalSkillRef[] ) { const fields = { messageId, expectedBodyFingerprint: expected, text, - ...(images ? { images } : {}) + ...(images ? { images } : {}), + ...(skills !== undefined ? { skills } : {}) } return rig.host.queuedMessageUpdate(QUEUED_RIG_CALLER, { envelope: rig.envelope( @@ -103,7 +108,7 @@ function held() { } describe('saving a queued card in place', () => { - it('keeps id, position and sender; text that reads like a command stays text', async () => { + it('keeps id and position and attributes an edited agent message to the user', async () => { await rig.workingSend() const from = { kind: 'agent' as const, senders: [], orchestration: null } const queued = await rig.send('original', 'queue-if-active', { internal: true, from }).result @@ -116,7 +121,8 @@ describe('saving a queued card in place', () => { ok: true, value: { status: 'updated' } }) - const body = { ...before.body, blocks: [{ type: 'text' as const, text: '/compact' }] } + const { from: _sender, ...plain } = before.body + const body = { ...plain, blocks: [{ type: 'text' as const, text: '/compact' }] } expect(journal().queuedMessages.get(id)).toEqual({ ...before, body, @@ -125,6 +131,93 @@ describe('saving a queued card in place', () => { expect(published(id)?.body).toEqual(body) }) + it('replaces skills including an empty set, reconciles lost replies and reopens the durable user-authored body', async () => { + await rig.workingSend() + const from = { kind: 'agent' as const, senders: [], orchestration: null } + const originalSkill = { name: 'review', path: '/remote/old/SKILL.md' } + const nextSkill = { name: 'review', path: '/remote/new/SKILL.md' } + const row = await journal().queuedMessages.insert({ + messageId: 'skill-card', + body: { + ...hostTestMessage('$review this'), + from, + skills: [originalSkill] + } + }) + expect(await update(row.messageId, row.fingerprint, '$review this')).toMatchObject({ + value: { status: 'unchanged' } + }) + expect(published(row.messageId)?.body.from).toEqual(from) + expect( + await update(row.messageId, '0'.repeat(64), '$review this', undefined, undefined, undefined, [ + nextSkill + ]) + ).toMatchObject({ value: { status: 'changed' } }) + expect(published(row.messageId)?.body.from).toEqual(from) + const operation = hostTestOperationId() + expect( + await update( + row.messageId, + row.fingerprint, + '$review this', + operation, + undefined, + undefined, + [nextSkill] + ) + ).toMatchObject({ value: { status: 'updated' } }) + expect( + await update( + row.messageId, + row.fingerprint, + '$review this', + operation, + undefined, + undefined, + [nextSkill] + ) + ).toMatchObject({ value: { status: 'unchanged' } }) + expect(published(row.messageId)?.body.skills).toEqual([nextSkill]) + expect(published(row.messageId)?.body).not.toHaveProperty('from') + await rig.restartHostProcess() + await rig.drafts() + expect(published(row.messageId)?.body.skills).toEqual([nextSkill]) + expect(published(row.messageId)?.body).not.toHaveProperty('from') + expect( + await update( + row.messageId, + fingerprint(row.messageId), + '$review this', + undefined, + undefined, + undefined, + [] + ) + ).toMatchObject({ value: { status: 'updated' } }) + expect(published(row.messageId)?.body).not.toHaveProperty('skills') + }) + + it('preserves omitted skills on a no-op from an old phone and drops them only on its content-changing edit', async () => { + await rig.workingSend() + const row = await journal().queuedMessages.insert({ + messageId: 'legacy-skills', + body: { + ...hostTestMessage('$review this'), + skills: [{ name: 'review', path: '/skills/review.md' }], + from: { kind: 'agent', senders: [], orchestration: null } + } + }) + expect(await update(row.messageId, row.fingerprint, '$review this')).toMatchObject({ + value: { status: 'unchanged' } + }) + expect(published(row.messageId)?.body.skills).toHaveLength(1) + expect(await update(row.messageId, row.fingerprint, '$review that')).toMatchObject({ + value: { status: 'updated' } + }) + expect(published(row.messageId)?.body).not.toHaveProperty('skills') + expect(published(row.messageId)?.body).not.toHaveProperty('from') + }) + it('a resent Save reads the card as it stands and never overwrites newer text', async () => { await rig.workingSend() const id = await queue('base') @@ -286,6 +379,58 @@ describe('edit leases and automatic delivery', () => { expect(journal().queuedMessages.editLeases.heldIds().size).toBe(0) }) + it('drains changed selected skills into the recorded Codex input shape by path', async () => { + const working = await rig.workingSend() + const id = await queue('$review original') + const selected = { name: 'review', path: '/work/repo/.agents/skills/review/SKILL.md' } + expect( + await update(id, fingerprint(id), '$review changed', undefined, undefined, undefined, [ + selected + ]) + ).toMatchObject({ value: { status: 'updated' } }) + await rig.settleAccepted(working, 'work-done') + await eventually(async () => expect(await rig.handoff(id)).toBeDefined()) + await eventually(() => + expect( + rig.dispatch.mock.calls.some(([input]) => + input.body.skills?.some((skill) => skill.path === selected.path) + ) + ).toBe(true) + ) + const consumed = rig.dispatch.mock.calls.find(([input]) => + input.body.skills?.some((skill) => skill.path === selected.path) + )?.[0] + expect(consumed?.body.skills).toEqual([selected]) + if (!consumed) { + throw new Error('queue did not dispatch') + } + const codex = fakeCodex({ + 'skills/list': () => ({ + data: [ + { + cwd: '/work/repo', + skills: [{ ...selected, description: 'Review changes', scope: 'user', enabled: true }], + errors: [] + } + ] + }), + 'turn/start': () => ({ turn: { id: 'turn-1' } }) + }) + const adapter = await acquired(codex) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: consumed.clientMessageId, + fence: 7, + body: consumed.body + }) + expect( + codex.connections[0].calls.find((call) => call.method === 'turn/start')?.params?.input + ).toEqual([ + { type: 'text', text: '$review changed' }, + { type: 'skill', ...selected } + ]) + }) + it('Save then release lets the edited card drain with its new text', async () => { const work = await rig.workingSend() const id = await queue('before') @@ -413,3 +558,109 @@ describe('edit leases and automatic delivery', () => { expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)).toBeUndefined() }) }) + +it('real lease replacement survives an old delayed release; delivery may beat later reacquisition', async () => { + await rig.workingSend() + const id = await queue('base') + let releaseOld: (() => Promise) | undefined + const request = (editId: string, expectedBodyFingerprint: string, delayRelease = false) => + startQueuedEditLease({ + sessionId: HOST_TEST_SESSION, + messageId: id, + editId, + expectedBodyFingerprint, + onLost: () => {}, + request: async (params) => { + if (params.action === 'release' && delayRelease) { + return new Promise((resolve) => { + releaseOld = async () => { + resolve(await rig.host.queuedMessageEditHold(QUEUED_RIG_CALLER, params)) + } + }) + } + return rig.host.queuedMessageEditHold(QUEUED_RIG_CALLER, params) + } + }) + const old = request('before-commit', fingerprint(id), true) + expect(await old.acquired).toMatchObject({ status: 'held' }) + expect(await update(id, fingerprint(id), 'accepted')).toMatchObject({ + value: { status: 'updated' } + }) + expect(held()).toEqual([]) + old.end() + const replacement = request('after-proof', fingerprint(id)) + expect(await replacement.acquired).toMatchObject({ status: 'held' }) + await eventually(async () => expect(releaseOld).toBeDefined()) + await releaseOld!() + expect(held()).toEqual([id]) + replacement.end() + await eventually(async () => expect(held()).toEqual([])) + await rig.sendNow(id) + const late = request('delivery-won', fingerprint(id)) + expect(await late.acquired).toMatchObject({ status: 'gone' }) + late.end() +}) + +it.each(['retry-not-editable', 'renew-gone'] as const)( + 'shared lease delivers %s from the actual host wire boundary', + async (mode) => { + await rig.workingSend() + const row = + mode === 'retry-not-editable' + ? await journal().queuedMessages.insert({ + messageId: 'wire-command', + body: { ...hostTestMessage('/compact'), command: { name: 'compact' } } + }) + : journal().queuedMessages.get(await queue('A'))! + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const onLost = vi.fn() + const request = vi.fn((params: Parameters[1]) => + rig.host.queuedMessageEditHold(QUEUED_RIG_CALLER, params) + ) + if (mode === 'retry-not-editable') { + request.mockRejectedValueOnce(new Error('offline')) + } + const lease = startQueuedEditLease({ + sessionId: HOST_TEST_SESSION, + messageId: row.messageId, + editId: 'wire-terminal', + expectedBodyFingerprint: row.fingerprint, + request, + onLost + }) + let missing: MockInstance | undefined + try { + if (mode === 'retry-not-editable') { + await expect(lease.acquired).rejects.toThrow('offline') + } else { + expect(await lease.acquired).toMatchObject({ status: 'held' }) + await rig.host.close(HOST_TEST_SESSION, 'evict') + missing = vi.spyOn(rig.store, 'getConversation').mockReturnValue(null) + } + await vi.advanceTimersByTimeAsync(30_000) + expect(onLost).toHaveBeenCalledOnce() + expect(onLost).toHaveBeenCalledWith({ + lease, + expected: row.fingerprint, + status: mode === 'retry-not-editable' ? 'not-editable' : 'gone' + }) + expect(request.mock.calls.map(([params]) => params.action)).toEqual([ + 'acquire', + mode === 'retry-not-editable' ? 'acquire' : 'renew' + ]) + lease.end() + await vi.advanceTimersByTimeAsync(0) + const count = request.mock.calls.length + await vi.advanceTimersByTimeAsync(120_000) + expect(request).toHaveBeenCalledTimes(count) + expect(onLost).toHaveBeenCalledOnce() + } finally { + lease.end() + await vi.advanceTimersByTimeAsync(0) + missing?.mockRestore() + warn.mockRestore() + vi.useRealTimers() + } + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.ts index 03d7424e4604..a870be552ce7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.ts @@ -1,4 +1,4 @@ -// Save compares body fingerprints without changing card identity/order/sender; edit leases block automatic delivery. +// Save compares body fingerprints without changing card identity/order; edit leases block automatic delivery. import type { AgentSessionMutationEnvelope, diff --git a/src/renderer/src/components/dictation/dictation-insertion-target.ts b/src/renderer/src/components/dictation/dictation-insertion-target.ts index b7985fb536f8..c17f40eeaee4 100644 --- a/src/renderer/src/components/dictation/dictation-insertion-target.ts +++ b/src/renderer/src/components/dictation/dictation-insertion-target.ts @@ -11,10 +11,26 @@ import { import { DICTATION_FOCUS_INPUT_EVENT } from './dictation-control-events' export type DictationInsertionTarget = + | { kind: 'composer'; insert: (text: string) => void } | { kind: 'terminal'; tabId: string; paneId: number } | { kind: 'text'; element: HTMLInputElement | HTMLTextAreaElement } | { kind: 'contentEditable'; element: HTMLElement } +const composerTargets = new WeakMap void>() + +/** Capture a draft destination so a late transcript can follow an inline edit's hand-back. */ +export function registerDictationComposerTarget( + root: HTMLElement, + insert: (text: string) => void +): () => void { + composerTargets.set(root, insert) + return () => { + if (composerTargets.get(root) === insert) { + composerTargets.delete(root) + } + } +} + export function captureInsertionTarget(): DictationInsertionTarget | null { const focused = readFocusedInsertionTarget() if (focused) { @@ -31,6 +47,13 @@ function readFocusedInsertionTarget(): DictationInsertionTarget | null { return null } + for (let root: Element | null = activeElement; root; root = root.parentElement) { + const insert = root instanceof HTMLElement ? composerTargets.get(root) : undefined + if (insert) { + return { kind: 'composer', insert } + } + } + if (activeElement.classList.contains('xterm-helper-textarea')) { const paneElement = activeElement.closest('.pane[data-pane-id]') as HTMLElement | null const tabElement = activeElement.closest('[data-terminal-tab-id]') as HTMLElement | null @@ -54,6 +77,10 @@ function readFocusedInsertionTarget(): DictationInsertionTarget | null { } export function insertText(text: string, target: DictationInsertionTarget): void { + if (target.kind === 'composer') { + target.insert(text) + return + } if (target.kind === 'terminal') { document.dispatchEvent( new CustomEvent('dictation:insertText', { diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx index b69d05382712..b54d95b89f54 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx @@ -1,4 +1,5 @@ -import { memo, useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' +import { memo, useEffect, useRef, useLayoutEffect, useState } from 'react' import { Package, RotateCcw } from 'lucide-react' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' @@ -233,14 +234,57 @@ function PickerListbox({ id: string children: React.ReactNode }): React.JSX.Element { + const marker = useRef(null) + const [bounds, setBounds] = useState(null) + useLayoutEffect(() => { + const anchor = marker.current?.parentElement + if (!anchor) { + return + } + const measure = (): void => { + const next = anchor.getBoundingClientRect() + setBounds((current) => + current?.left === next.left && current.width === next.width && current.top === next.top + ? current + : next + ) + } + const ancestors: HTMLElement[] = [] + for (let parent: HTMLElement | null = anchor; parent; parent = parent.parentElement) { + ancestors.push(parent) + } + measure() + const observer = new ResizeObserver(measure) + observer.observe(anchor) + ancestors.forEach((parent) => parent.addEventListener('scroll', measure)) + window.addEventListener('resize', measure) + return () => { + observer.disconnect() + ancestors.forEach((parent) => parent.removeEventListener('scroll', measure)) + window.removeEventListener('resize', measure) + } + }, []) return ( -
- {children} -
+ <> + + {bounds && + createPortal( +
+ {children} +
, + marker.current?.closest('.native-chat-appearance') ?? document.body + )} + ) } diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.resume-command.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.resume-command.test.tsx index 098b3b1ac36c..5c687735b47b 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.resume-command.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.resume-command.test.tsx @@ -4,12 +4,15 @@ import { act, cleanup, render } from '@testing-library/react' import type { RefObject } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatPickerItem } from '../../../../shared/native-chat/input/native-chat-picker-items' +import type { QueuedMessageInlineEditor } from './use-structured-agent-session-queued-edit' import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' type CommandItem = Extract type FieldProps = { textareaRef: RefObject + autocomplete?: { mode: string; items?: NativeChatPickerItem[] } + onChooseMentionFile?: (path: string) => void onSend?: () => void onChoosePickerItem?: (item: NativeChatPickerItem) => void } @@ -38,6 +41,9 @@ vi.mock('../../store', () => { activeWorkspaceExecutionHostId: 'ssh:box', tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, unifiedTabsByWorktree: {}, + worktreesByRepo: {}, + detectedWorktreesByRepo: {}, + folderWorkspaces: [], clearNativeChatLaunchDraft: vi.fn() } const useAppStore = (selector: (value: typeof state) => unknown) => selector(state) @@ -81,6 +87,10 @@ vi.mock('./use-native-chat-composer-keydown', () => ({ } })) +import { + createNativeChatQueuedEditDraft, + discardNativeChatQueuedEdit +} from './native-chat-queued-edit-draft' import { NativeChatComposer } from './NativeChatComposer' const RESUME: CommandItem = { @@ -92,9 +102,10 @@ const RESUME: CommandItem = { orcaOwned: true } -function renderStructuredChat() { +function renderStructuredChat(editSession?: QueuedMessageInlineEditor) { const transport: NativeChatStructuredComposerTransport = { capturePresentation: () => () => true, + ...(editSession ? { threadGoal: { setObjective: vi.fn(async () => true) } } : {}), send: vi.fn(() => true), dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })), optionsSurface: { @@ -113,6 +124,8 @@ function renderStructuredChat() { const chat = () => ( { expect(mocks.openHistory).toHaveBeenCalledOnce() }) + it('reopened native slash identity permits only edit Save and cannot authorize a stale command row', () => { + const draft = createNativeChatQueuedEditDraft( + { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/model\nintent' }], + skills: [{ name: 'model', path: '/selected/SKILL.md' }] + }, + 'bottom' + ) + mocks.draft = '/model\nintent' + const editor: QueuedMessageInlineEditor = { + messageId: 'seeded', + draftScopeKey: draft.scopeKey, + text: mocks.draft, + change: vi.fn(), + acquiring: false, + saving: false, + canSave: true, + imagesCapable: true, + save: vi.fn(), + cancel: vi.fn() + } + const { transport } = renderStructuredChat(editor) + act(() => mocks.fieldProps?.onSend?.()) + expect(editor.save).toHaveBeenCalledOnce() + act(() => + mocks.fieldProps?.onChoosePickerItem?.({ + ...RESUME, + id: 'command:model', + name: 'model', + token: '/model' + }) + ) + expect(editor.save).toHaveBeenCalledOnce() + expect(transport.dispatchCommand).not.toHaveBeenCalled() + expect(transport.send).not.toHaveBeenCalled() + discardNativeChatQueuedEdit(draft) + }) + + it.each(['/compact', '/goal ship it', '/resume old chat'])( + 'saves literal %s but refuses its picked control without dispatch', + (text) => { + mocks.draft = text + const editor: QueuedMessageInlineEditor = { + messageId: 'queued', + draftScopeKey: 'queued-edit:test', + text, + change: vi.fn(), + acquiring: false, + saving: false, + canSave: true, + imagesCapable: true, + save: vi.fn(), + cancel: vi.fn() + } + const { transport } = renderStructuredChat(editor) + act(() => mocks.fieldProps?.onSend?.()) + const name = text.slice(1).split(' ')[0]! + const item: CommandItem = { ...RESUME, id: `command:${name}`, name, token: `/${name}` } + act(() => mocks.keyDownArgs?.dispatchPickerCommand(item)) + act(() => mocks.fieldProps?.onChoosePickerItem?.(item)) + expect(editor.save).toHaveBeenCalledOnce() + expect(transport.send).not.toHaveBeenCalled() + expect(transport.threadGoal?.setObjective).not.toHaveBeenCalled() + expect(transport.dispatchCommand).not.toHaveBeenCalled() + expect(mocks.sendNativeChatTypedCommand).not.toHaveBeenCalled() + expect(mocks.openHistory).not.toHaveBeenCalled() + expect(mocks.setDraft).not.toHaveBeenCalledWith('') + } + ) + + it('saves ordinary text and a picked provider skill without any send or command RPC', () => { + mocks.draft = '/res' + const editor: QueuedMessageInlineEditor = { + messageId: 'queued', + draftScopeKey: 'queued-edit:test', + text: '/res', + change: vi.fn(), + acquiring: false, + saving: false, + canSave: true, + imagesCapable: true, + save: vi.fn(), + cancel: vi.fn() + } + const { transport, rerender } = renderStructuredChat(editor) + act(() => + mocks.fieldProps?.onChoosePickerItem?.({ + kind: 'skill', + id: 'skill:resume', + name: 'resume', + token: '/resume', + description: null, + sources: [] + }) + ) + mocks.draft = '/resume write a page' + rerender() + act(() => mocks.fieldProps?.onSend?.()) + expect(editor.save).toHaveBeenCalledOnce() + expect(transport.send).not.toHaveBeenCalled() + expect(transport.dispatchCommand).not.toHaveBeenCalled() + expect(mocks.openHistory).not.toHaveBeenCalled() + }) + // A terminal-backed chat (every SSH chat, for one) keeps the agent's own /resume. it('leaves /resume to the agent in a terminal-backed chat', () => { render( @@ -208,4 +327,47 @@ describe('/resume in the native chat composer', () => { expect(mocks.openHistory).not.toHaveBeenCalled() expect(mocks.sendNativeChatTypedCommand).toHaveBeenCalledWith('pty-1', '/resume') }) + + it('derives inline catalog from the same action boundary before resolving namesake skills', () => { + mocks.draft = '/' + const edit: QueuedMessageInlineEditor = { + messageId: 'queue', + draftScopeKey: 'queued-edit:menu', + text: '/', + acquiring: false, + saving: false, + canSave: true, + imagesCapable: true, + save: vi.fn(), + cancel: vi.fn(), + change: vi.fn() + } + const { transport, rerender } = renderStructuredChat(edit) + transport.sessionCommands = [ + { name: 'review', kind: 'command' }, + { name: 'model', kind: 'skill', path: '/model/SKILL.md' }, + { name: 'goal', kind: 'command' } + ] + transport.conversationCommands = ['clear', 'compact'] + rerender() + expect( + mocks.fieldProps?.autocomplete?.items?.map(({ kind, name }) => `${kind}:${name}`) + ).toEqual(['command:review', 'skill:model']) + expect(mocks.openHistory).not.toHaveBeenCalled() + // Without a host goal action /goal is valid provider prompt input. + delete transport.threadGoal + rerender() + expect(mocks.fieldProps?.autocomplete?.items?.map(({ name }) => name)).toContain('goal') + mocks.draft = '/goal objective' + rerender() + act(() => mocks.fieldProps?.onSend?.()) + expect(edit.save).toHaveBeenCalledOnce() + expect(transport.send).not.toHaveBeenCalled() + expect(transport.dispatchCommand).not.toHaveBeenCalled() + mocks.draft = '@src' + rerender() + expect(mocks.fieldProps?.autocomplete?.mode).toBe('mention') + act(() => mocks.fieldProps?.onChooseMentionFile?.('src/file.ts')) + expect(mocks.setDraft).toHaveBeenCalledWith(expect.stringContaining('src/file.ts')) + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 97e5c9a42255..9a83769bd33e 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -1,420 +1,14 @@ -import { useNativeChatComposerNotice } from './use-native-chat-composer-notice' -import { useNativeChatMobileHoldNotice } from './use-native-chat-mobile-hold-notice' -import type { NativeChatComposerInput } from './native-chat-composer-input' -import { forwardRef, useCallback, useState } from 'react' -import { useNativeChatComposerInterrupt } from './use-native-chat-composer-interrupt' -import { useNativeChatContextUsageSummary } from './use-native-chat-context-usage-summary' -import { useNativeChatMentionFiles } from './use-native-chat-mention-files' -import { useNativeChatDraft } from './use-native-chat-draft' -import { useNativeChatComposerRecall } from './use-native-chat-composer-recall' -import { useNativeChatLaunchDraftAdoption } from './use-native-chat-launch-draft-adoption' -import { NativeChatComposerField } from './NativeChatComposerField' -import type { NativeChatResolvedTarget } from './native-chat-composer-target' -import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' -import { nativeChatImageSendBlock } from './native-chat-image-reattach' -import { useNativeChatComposerHandle } from './use-native-chat-composer-handle' -import { useNativeChatFileDrops } from './use-native-chat-file-drops' -import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' -import { useNativeChatSendLifecycle } from './use-native-chat-send-lifecycle' -import { useNativeChatSessionOptions } from './use-native-chat-session-options' -import { useNativeChatDictation } from './use-native-chat-dictation' -import { useNativeChatSessionOptionCommand } from './use-native-chat-session-option-command' -import { useNativeChatPickerCommandDispatch } from './use-native-chat-picker-command-dispatch' +import { forwardRef } from 'react' +import { NativeChatComposerPane } from './NativeChatComposerPane' import type { NativeChatComposerHandle, NativeChatComposerProps } from './native-chat-composer-types' -import { useNativeChatPtyComposerSend } from './use-native-chat-pty-composer-send' -import { useNativeChatStructuredComposerSend } from './use-native-chat-structured-composer-send' -import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' -import { useNativeChatComposerAppMenuSelection } from './use-native-chat-composer-app-menu-selection' -import { useNativeChatComposerSubmit } from './use-native-chat-composer-submit' -import { useNativeChatComposerCommandPicker } from './use-native-chat-composer-command-picker' - export type { NativeChatComposerHandle, NativeChatComposerProps } from './native-chat-composer-types' -/** Send through the chat’s structured transport or verified PTY route, with agent-aware autocomplete and image handling. */ -const NativeChatComposerPane = forwardRef( - function NativeChatComposerPane( - { - terminalTabId, - paneKey, - draftScopeKey = paneKey, - targetPtyId, - agent, - canSend = true, - unavailableHint, - isWorking = false, - isStopping = false, - afterStop, - onStop, - onOptimisticSend, - optimisticSendOutcome, - onOptimisticSendCanceled, - onSlashCommand, - onSubmitted, - answerCommandLocally, - onSwitchToTerminal, - readTerminalScreen, - launchSeed, - structuredTransport, - steerQueued, - recallSource, - inputOwnedByCard = false, - notices: chatNotices - }, - ref - ): React.JSX.Element { - // Own drafts and attachments by pane/conversation so reconnect PTY replacement and UI mode changes cannot discard unsent input. - const imeEnterGesture = useImeEnterGestureOwnership() - const { draft, setDraft, flushDraftAppends } = useNativeChatDraft( - draftScopeKey, - imeEnterGesture.isComposing - ) - const [caret, setCaret] = useState(draft.length) - useNativeChatLaunchDraftAdoption({ - terminalTabId, - agent, - launchDraft: launchSeed?.launchDraft, - launchDraftResolved: launchSeed?.launchDraftResolved === true, - ownsTabWideLaunchDraft: launchSeed?.ownsTabWideLaunchDraft === true, - draft, - setDraft, - setCaret - }) - const [activeSuggestion, setActiveSuggestion] = useState(0) - const holdNotice = useNativeChatMobileHoldNotice( - structuredTransport ? null : targetPtyId, - canSend - ) - const { notices, setNotice } = useNativeChatComposerNotice(chatNotices, holdNotice) - const { textareaRef } = useNativeChatComposerAppMenuSelection(imeEnterGesture.isComposing) - const recall = useNativeChatComposerRecall({ - draft, - source: recallSource, - inputRef: textareaRef - }) - const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle( - terminalTabId, - targetPtyId, - onOptimisticSendCanceled, - { inputOwnedByCard, onPendingSendRetired: optimisticSendOutcome?.reject } - ) - - // Why inputOwnedByCard: the hidden field can still hold keyboard focus for a frame. - const [hasPty, disabled] = structuredTransport - ? [true, !canSend || unavailableHint !== undefined] - : [targetPtyId !== null, targetPtyId === null || !canSend || inputOwnedByCard] - - const { picker, resume } = useNativeChatComposerCommandPicker({ - agent, - terminalTabId, - draftScopeKey: paneKey, - draft, - caret, - recalledFromHistory: recall.active, - disabled, - isComposing: imeEnterGesture.isComposing, - structuredTransport, - editor: { textareaRef, setDraft, setCaret, setActiveSuggestion }, - setNotice - }) - const { - autocomplete, - classifySend, - clearSkillOrigin, - completeItem, - completeMention, - dismiss, - handleDraftOrCaretChange - } = picker - const mentionFiles = useNativeChatMentionFiles({ - query: autocomplete.mode === 'mention' ? autocomplete.query : null, - terminalTabId, - structuredWorktreeId: structuredTransport?.worktreeId - }) - - const resolveTarget = useCallback((): NativeChatResolvedTarget | null => { - if (!targetPtyId) { - return null - } - return { ptyId: targetPtyId } - }, [targetPtyId]) - - const attachments = useNativeChatComposerAttachments({ - attachmentScopeKey: draftScopeKey, - allowWithoutTarget: Boolean(structuredTransport), - acceptsImages: structuredTransport?.acceptsImages !== false, - imageReferences: Boolean(structuredTransport), - caret, - disabled, - isComposing: imeEnterGesture.isComposing, - resolveTarget, - textareaRef, - setCaret, - setDraft, - setNotice - }) - const { imageAttachments, attachResolvedPaths, clearImageAttachments, removeImageAttachment } = - attachments - const imageBlock = nativeChatImageSendBlock(imageAttachments) - const sendBlockedReason = imageBlock.reason ?? structuredTransport?.sendBlockedReason ?? null - const sendHeld = - attachments.pastedTextSaving || - disabled || - structuredTransport?.sendOut === true || - imageBlock.holdsSend || - structuredTransport?.sendBlockedReason !== undefined - const sendButtonDisabled = isWorking - ? !hasPty || !onStop - : sendHeld || (draft.trim() === '' && imageAttachments.length === 0) - - const { pickAttachments, attachExternalPaths, captureCopiedFiles, resolveAttachmentOwner } = - useNativeChatFileDrops({ - paneKey, - draftScopeKey, - targetPtyId, - structuredTransport, - terminalTabId, - structuredWorktreeId: structuredTransport?.worktreeId, - structuredSession: structuredTransport, - disabled, - attachResolvedPaths, - pendingChips: attachments.pendingChips, - setNotice - }) - - const handlePasteEvent = useNativeChatComposerHandle(ref, { - attachmentScopeKey: draftScopeKey, - textareaRef, - isComposing: imeEnterGesture.isComposing, - caret, - setDraft, - setCaret, - setActiveSuggestion, - targetKey: JSON.stringify([ - paneKey, - targetPtyId, - structuredTransport?.sessionId, - structuredTransport?.worktreeId, - structuredTransport?.runtimeEnvironmentId - ]), - agent, - disabled, - resolveAttachmentOwner, - attachResolvedPaths, - attachCopiedFiles: attachExternalPaths, - captureCopiedFiles, - beginPendingImageAttachment: attachments.beginPendingImageAttachment, - // Settles into the scope cache, which outlives a composer a prompt card unmounted. - resolvePendingImageAttachment: attachments.pendingChips.resolve, - revealPendingImageAttachment: attachments.revealPendingImageAttachment, - dropPendingImageAttachment: attachments.dropPendingImageAttachment, - pastesInline: () => goalMode.active, - pastedTextEditor: structuredTransport ? attachments.scopeEditor : null, - setNotice - }) - - const dictation = useNativeChatDictation(textareaRef, disabled) - const { dispatch: dispatchSessionOptionCommand, isDispatching: isDispatchingSessionOption } = - useNativeChatSessionOptionCommand({ - agent, - disabled, - onSlashCommand, - onSubmitted, - resolveTarget - }) - - const { surface: ptySessionOptionsSurface, snapshot: ptySessionOptionsSnapshot } = - useNativeChatSessionOptions({ - agent, - terminalTabId, - targetPtyId, - dispatchCommand: dispatchSessionOptionCommand, - onAgentPicker: onSwitchToTerminal, - readTerminalScreen, - paneKey - }) - const sessionOptionsSurface = structuredTransport?.optionsSurface ?? ptySessionOptionsSurface - const contextUsageSummary = useNativeChatContextUsageSummary(structuredTransport) - const sessionOptionsSnapshot = structuredTransport?.optionSnapshot ?? ptySessionOptionsSnapshot - - const sendStructured = useNativeChatStructuredComposerSend({ - agent, - draftScopeKey, - imageAttachments, - structuredTransport, - isComposing: imeEnterGesture.isComposing, - clearSkillOrigin, - setDraft, - setCaret - }) - - const ptyCommandRouting = { - agent, - disabled, - isDispatchingSessionOption, - resolveTarget, - onSlashCommand, - onSubmitted, - answerCommandLocally, - sessionOptionsSurface: ptySessionOptionsSurface, - trackPendingSend, - setDraft, - setCaret, - clearSkillOrigin, - clearImageAttachments, - setNotice - } - const sendPty = useNativeChatPtyComposerSend({ - draftScopeKey, - ...ptyCommandRouting, - draft, - imageAttachments, - launchDraft: launchSeed?.launchDraft, - launchDraftResolved: launchSeed?.launchDraftResolved === true, - readTerminalScreen, - classifySend, - onOptimisticSend, - optimisticSendOutcome, - terminalTabId - }) - const { send, goalMode } = useNativeChatComposerSubmit({ - structuredTransport, - draftScopeKey, - draft, - caret, - imageAttachments, - disabled, - sendPty, - sendStructured, - consumeOrcaCommand: resume.consume, - setDraft, - setCaret - }) - - const interrupt = useNativeChatComposerInterrupt({ - inert: inputOwnedByCard, - cancelPendingSends, - isWorking, - onStop, - resolveTarget - }) - - const dispatchPtyPickerCommand = useNativeChatPickerCommandDispatch({ - ...ptyCommandRouting, - setActiveSuggestion - }) - const dispatchPickerCommand = useCallback( - (command: Parameters[0]) => - structuredTransport - ? sendStructured(`/${command.name}`) - : dispatchPtyPickerCommand(command), - [dispatchPtyPickerCommand, sendStructured, structuredTransport] - ) - - const handleKeyDown = useNativeChatComposerKeyDown({ - autocomplete, - mentionFiles, - completeMention, - activeSuggestion, - draft, - recall: recall.recall, - isComposing: imeEnterGesture.isComposing, - completePickerItem: goalMode.interceptPick(completeItem), - dispatchPickerCommand: goalMode.interceptPick(resume.interceptPick(dispatchPickerCommand)), - dismissPicker: dismiss, - interrupt, - send, - hasAttachments: imageAttachments.length > 0, - ...(steerQueued ? { steerQueued } : {}), - setActiveSuggestion, - setDraft, - setCaret - }) - - const handleDraftChange = useCallback( - (value: string, element: NativeChatComposerInput) => { - const nextCaret = element.selectionStart ?? value.length - setDraft(value) - setCaret(nextCaret) - handleDraftOrCaretChange(value, nextCaret) - setActiveSuggestion(0) - }, - [handleDraftOrCaretChange, setDraft] - ) - - return ( - { - const value = element.value - const nextCaret = element.selectionStart ?? value.length - setCaret(nextCaret) - handleDraftOrCaretChange(value, nextCaret) - setActiveSuggestion(0) - }} - onKeyDown={handleKeyDown} - onImeSettled={(element) => { - if (element.value !== draft) { - handleDraftChange(element.value, element) - } - flushDraftAppends() - attachments.flushPendingAttachments() - }} - onPaste={handlePasteEvent} - pickerListboxId={picker.listboxId} - onChoosePickerItem={goalMode.interceptPick(resume.interceptPick(completeItem))} - goalMode={goalMode} - onRetrySkills={picker.retrySkills} - onChooseMentionFile={completeMention} - onRemoveImageAttachment={(id) => removeImageAttachment(id)} - onAttach={pickAttachments} - onDictationToggle={dictation.toggleDictation} - onDictationHoldStart={dictation.startHoldDictation} - onDictationHoldEnd={dictation.stopHoldDictation} - onSend={send} - onStop={interrupt} - sessionOptionsSurface={sessionOptionsSurface} - sessionOptionsSnapshot={sessionOptionsSnapshot} - contextUsage={contextUsageSummary} - sessionOptionsPickerRequest={structuredTransport?.optionPickerRequest ?? null} - agent={agent} - /> - ) - } -) - export const NativeChatComposer = forwardRef( function NativeChatComposer(props, ref): React.JSX.Element { return diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx index 204fd8d0b8f4..565558895fc5 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -14,6 +14,7 @@ import type { NativeChatContextUsageSummary } from './native-chat-context-usage- import type { NativeChatOptionPickerRequest } from './native-chat-composer-types' export type NativeChatComposerActionsProps = { + editSession?: { cancel: () => void } attachDisabled: boolean dictationDisabled: boolean sendDisabled: boolean @@ -45,6 +46,7 @@ export type NativeChatComposerActionsProps = { } export function NativeChatComposerActions({ + editSession, attachDisabled, dictationDisabled, sendDisabled, @@ -84,7 +86,17 @@ export function NativeChatComposerActions({ ? translate('components.native-chat.composer.stopDictation', 'Stop dictation') : translate('components.native-chat.composer.startDictation', 'Start dictation') const sendReason = stops ? null : (sendBlockedReason ?? null) - const sendButton = ( + const sendButton = editSession ? ( + + ) : ( + ) : null} {sendReason ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index d9054c279851..a37b46416a05 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -30,6 +30,7 @@ import { translate } from '@/i18n/i18n' import { useNativeChatComposerDraftUnsaved } from './use-native-chat-draft-unsaved' export type NativeChatComposerFieldProps = { + editSession?: { cancel: () => void } /** Own draft persistence and native-drop routing by this pane so drops cannot land in another composer. */ draftScopeKey: string textareaRef: RefObject @@ -117,6 +118,7 @@ function imeComposedSegment(base: string, settled: string): string { } export function NativeChatComposerField({ + editSession, draftScopeKey, textareaRef, draft, @@ -316,12 +318,14 @@ export function NativeChatComposerField({ : undefined } placeholder={ - goalMode?.active - ? translate( - 'components.native-chat.goal.placeholder', - 'Describe your goal, define measurable outcomes for best results' - ) - : (unavailableHint ?? nativeChatComposerPlaceholder(hasPty, canSend, afterStop)) + editSession + ? translate('components.native-chat.queuedMessages.editMessage', 'Edit message') + : goalMode?.active + ? translate( + 'components.native-chat.goal.placeholder', + 'Describe your goal, define measurable outcomes for best results' + ) + : (unavailableHint ?? nativeChatComposerPlaceholder(hasPty, canSend, afterStop)) } // Let layout handle wrapping and touch targets; scroll after eight lines without JavaScript measurement. className={cn( @@ -333,6 +337,7 @@ export function NativeChatComposerField({ />
( + function NativeChatComposerPane( + { + terminalTabId, + editSession, + paneKey, + draftScopeKey = paneKey, + targetPtyId, + agent, + canSend = true, + unavailableHint, + isWorking = false, + isStopping = false, + afterStop, + onStop, + onOptimisticSend, + optimisticSendOutcome, + onOptimisticSendCanceled, + onSlashCommand, + onSubmitted, + answerCommandLocally, + onSwitchToTerminal, + readTerminalScreen, + launchSeed, + structuredTransport, + steerQueued, + recallSource, + inputOwnedByCard = false, + notices: chatNotices + }, + ref + ): React.JSX.Element { + // Own drafts and attachments by pane/conversation so reconnect PTY replacement and UI mode changes cannot discard unsent input. + const imeEnterGesture = useImeEnterGestureOwnership() + const { draft, setDraft, flushDraftAppends } = useNativeChatDraft( + draftScopeKey, + imeEnterGesture.isComposing + ) + const [caret, setCaret] = useState(draft.length) + useNativeChatLaunchDraftAdoption({ + terminalTabId, + agent, + launchDraft: launchSeed?.launchDraft, + launchDraftResolved: launchSeed?.launchDraftResolved === true, + ownsTabWideLaunchDraft: launchSeed?.ownsTabWideLaunchDraft === true, + draft, + setDraft, + setCaret + }) + const [activeSuggestion, setActiveSuggestion] = useState(0) + const holdNotice = useNativeChatMobileHoldNotice( + structuredTransport ? null : targetPtyId, + canSend + ) + const { notices, setNotice } = useNativeChatComposerNotice(chatNotices, holdNotice) + const { textareaRef } = useNativeChatComposerAppMenuSelection(imeEnterGesture.isComposing) + const recall = useNativeChatComposerRecall({ + draft, + source: recallSource, + inputRef: textareaRef + }) + const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle( + terminalTabId, + targetPtyId, + onOptimisticSendCanceled, + { inputOwnedByCard, onPendingSendRetired: optimisticSendOutcome?.reject } + ) + + // Why inputOwnedByCard: the hidden field can still hold keyboard focus for a frame. + const [hasPty, disabled] = structuredTransport + ? [true, !canSend || unavailableHint !== undefined] + : [targetPtyId !== null, targetPtyId === null || !canSend || inputOwnedByCard] + + const { picker, resume } = useNativeChatComposerCommandPicker({ + editing: editSession !== undefined, + agent, + terminalTabId, + draftScopeKey: paneKey, + draft, + caret, + recalledFromHistory: recall.active, + disabled, + isComposing: imeEnterGesture.isComposing, + structuredTransport, + editor: { textareaRef, setDraft, setCaret, setActiveSuggestion }, + setNotice + }) + const { + autocomplete, + classifySend, + clearSkillOrigin, + completeItem, + completeMention, + dismiss, + handleDraftOrCaretChange + } = picker + const mentionFiles = useNativeChatMentionFiles({ + query: autocomplete.mode === 'mention' ? autocomplete.query : null, + terminalTabId, + structuredWorktreeId: structuredTransport?.worktreeId + }) + + const resolveTarget = useCallback(() => { + if (!targetPtyId) { + return null + } + return { ptyId: targetPtyId } + }, [targetPtyId]) + + const attachments = useNativeChatComposerAttachments({ + attachmentScopeKey: draftScopeKey, + allowWithoutTarget: Boolean(structuredTransport), + acceptsImages: structuredTransport?.acceptsImages !== false, + imageReferences: Boolean(structuredTransport), + caret, + disabled, + isComposing: imeEnterGesture.isComposing, + resolveTarget, + textareaRef, + setCaret, + setDraft, + setNotice + }) + const { imageAttachments, attachResolvedPaths, clearImageAttachments, removeImageAttachment } = + attachments + const imageBlock = nativeChatImageSendBlock(imageAttachments) + const sendBlockedReason = imageBlock.reason ?? structuredTransport?.sendBlockedReason ?? null + const sendHeld = + attachments.pastedTextSaving || + disabled || + structuredTransport?.sendOut === true || + imageBlock.holdsSend || + structuredTransport?.sendBlockedReason !== undefined + const sendButtonDisabled = editSession + ? sendHeld || !editSession.canSave + : isWorking + ? !hasPty || !onStop + : sendHeld || (draft.trim() === '' && imageAttachments.length === 0) + + const { + pickAttachments, + attachExternalPaths, + captureExternalPaths, + captureCopiedFiles, + resolveAttachmentOwner + } = useNativeChatFileDrops({ + paneKey, + draftScopeKey, + targetPtyId, + structuredTransport, + terminalTabId, + structuredWorktreeId: structuredTransport?.worktreeId, + structuredSession: structuredTransport, + disabled, + attachResolvedPaths, + pendingChips: attachments.pendingChips, + setNotice + }) + + const localHandle = useRef(null) + const root = useRef(null) + const handlePasteEvent = useNativeChatComposerHandle(localHandle, { + attachmentScopeKey: draftScopeKey, + textareaRef, + isComposing: imeEnterGesture.isComposing, + caret, + setDraft, + setCaret, + setActiveSuggestion, + targetKey: JSON.stringify([ + paneKey, + targetPtyId, + structuredTransport?.sessionId, + structuredTransport?.worktreeId, + structuredTransport?.runtimeEnvironmentId + ]), + agent, + disabled, + resolveAttachmentOwner, + attachResolvedPaths, + attachCopiedFiles: attachExternalPaths, + captureCopiedFiles, + captureExternalPaths, + beginPendingImageAttachment: attachments.beginPendingImageAttachment, + // Settles into the scope cache, which outlives a composer a prompt card unmounted. + resolvePendingImageAttachment: attachments.pendingChips.resolve, + revealPendingImageAttachment: attachments.revealPendingImageAttachment, + dropPendingImageAttachment: attachments.dropPendingImageAttachment, + pastesInline: () => goalMode.active, + pastedTextEditor: structuredTransport ? attachments.scopeEditor : null, + setNotice + }) + + useImperativeHandle(ref, () => localHandle.current!) + const dictation = useNativeChatDictation(textareaRef, disabled) + const { dispatch: dispatchSessionOptionCommand, isDispatching: isDispatchingSessionOption } = + useNativeChatSessionOptionCommand({ + agent, + disabled, + onSlashCommand, + onSubmitted, + resolveTarget + }) + + const { surface: ptySessionOptionsSurface, snapshot: ptySessionOptionsSnapshot } = + useNativeChatSessionOptions({ + agent, + terminalTabId, + targetPtyId, + dispatchCommand: dispatchSessionOptionCommand, + onAgentPicker: onSwitchToTerminal, + readTerminalScreen, + paneKey + }) + const sessionOptionsSurface = structuredTransport?.optionsSurface ?? ptySessionOptionsSurface + const contextUsageSummary = useNativeChatContextUsageSummary(structuredTransport) + const sessionOptionsSnapshot = structuredTransport?.optionSnapshot ?? ptySessionOptionsSnapshot + + const sendStructured = useNativeChatStructuredComposerSend({ + agent, + draftScopeKey, + imageAttachments, + structuredTransport, + isComposing: imeEnterGesture.isComposing, + clearSkillOrigin, + setDraft, + setCaret + }) + + const ptyCommandRouting = { + agent, + disabled, + isDispatchingSessionOption, + resolveTarget, + onSlashCommand, + onSubmitted, + answerCommandLocally, + sessionOptionsSurface: ptySessionOptionsSurface, + trackPendingSend, + setDraft, + setCaret, + clearSkillOrigin, + clearImageAttachments, + setNotice + } + const sendPty = useNativeChatPtyComposerSend({ + draftScopeKey, + ...ptyCommandRouting, + draft, + imageAttachments, + launchDraft: launchSeed?.launchDraft, + launchDraftResolved: launchSeed?.launchDraftResolved === true, + readTerminalScreen, + classifySend, + onOptimisticSend, + optimisticSendOutcome, + terminalTabId + }) + const { send: sendMessage, goalMode } = useNativeChatComposerSubmit({ + structuredTransport, + draftScopeKey, + draft, + caret, + imageAttachments, + disabled, + sendPty, + sendStructured, + consumeOrcaCommand: resume.consume, + setDraft, + setCaret + }) + + const { send, chooseEditItem, fieldOverrides, dispatchPickerCommand } = + useNativeChatComposerEditSession({ + editSession, + root, + handle: localHandle, + draftScopeKey, + agent, + disabled, + sendHeld, + ...picker, + structuredTransport, + sendStructured, + ptyCommandRouting: { ...ptyCommandRouting, setActiveSuggestion }, + setNotice, + sendMessage + }) + + const interrupt = useNativeChatComposerInterrupt({ + inert: inputOwnedByCard, + cancelPendingSends, + isWorking, + onStop, + resolveTarget + }) + + const handleKeyDown = useNativeChatComposerKeyDown({ + autocomplete, + mentionFiles, + completeMention, + activeSuggestion, + draft, + recall: editSession ? undefined : recall.recall, + isComposing: imeEnterGesture.isComposing, + completePickerItem: editSession ? chooseEditItem : goalMode.interceptPick(completeItem), + dispatchPickerCommand: editSession + ? chooseEditItem + : goalMode.interceptPick(resume.interceptPick(dispatchPickerCommand)), + dismissPicker: dismiss, + interrupt: editSession?.cancel ?? interrupt, + send, + hasAttachments: imageAttachments.length > 0, + ...(!editSession && steerQueued ? { steerQueued } : {}), + setActiveSuggestion, + setDraft, + setCaret + }) + + const draftEvents = useNativeChatDraftEvents({ + draft, + setDraft, + setCaret, + setActiveSuggestion, + handleDraftOrCaretChange, + flushDraftAppends, + flushPendingAttachments: attachments.flushPendingAttachments + }) + + return ( +
+ removeImageAttachment(id)} + onAttach={pickAttachments} + onDictationToggle={dictation.toggleDictation} + onDictationHoldStart={dictation.startHoldDictation} + onDictationHoldEnd={dictation.stopHoldDictation} + onSend={send} + onStop={interrupt} + sessionOptionsSurface={sessionOptionsSurface} + sessionOptionsSnapshot={sessionOptionsSnapshot} + contextUsage={contextUsageSummary} + sessionOptionsPickerRequest={structuredTransport?.optionPickerRequest ?? null} + agent={agent} + {...fieldOverrides} + /> +
+ ) + } +) diff --git a/src/renderer/src/components/native-chat/NativeChatPaneFileDropSurface.tsx b/src/renderer/src/components/native-chat/NativeChatPaneFileDropSurface.tsx index f62a73858f86..222d42cf012c 100644 --- a/src/renderer/src/components/native-chat/NativeChatPaneFileDropSurface.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPaneFileDropSurface.tsx @@ -1,4 +1,12 @@ -import { createContext, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { + createContext, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' import { Paperclip } from 'lucide-react' import { translate } from '@/i18n/i18n' import { toast } from 'sonner' @@ -6,16 +14,20 @@ import { createOsFileDropSequence, useOsFileDropOwner } from '@/hooks/use-os-fil import { getNativeFileDropRejectionMessage } from '@/lib/native-file-drop-rejection-message' import { makeNativeChatPaneFileDropHandlers, - type NativeChatPaneDropClaim + type NativeChatPaneDropClaim, + type CapturedNativeChatDrop } from './native-chat-pane-file-drop' /** Read the mounted composer’s claim at event time so guard changes apply to the current drag. */ export type NativeChatPaneDropRegistration = { getClaim: () => NativeChatPaneDropClaim destinationKey: string + focusScopeKey?: string } -type RegisterPaneDropClaim = (registration: NativeChatPaneDropRegistration) => () => void +type RegisterPaneDropClaim = ((registration: NativeChatPaneDropRegistration) => () => void) & { + clearHover?: () => void +} const NativeChatPaneFileDropContext = createContext(null) @@ -26,13 +38,18 @@ export function useNativeChatPaneFileDropClaim(claim: NativeChatPaneDropClaim): useLayoutEffect(() => { claimRef.current = claim }) - const { destinationKey, disabled } = claim + useLayoutEffect(() => { + if (claim.disabled) { + register?.clearHover?.() + } + }, [claim.disabled, register]) + const { destinationKey, focusScopeKey } = claim const registration = useMemo( - () => ({ getClaim: () => claimRef.current, destinationKey }), - [destinationKey] + () => ({ getClaim: () => claimRef.current, destinationKey, focusScopeKey }), + [destinationKey, focusScopeKey] ) // A guard transition ends the current hover before the next paint. - useLayoutEffect(() => register?.(registration), [disabled, register, registration]) + useLayoutEffect(() => register?.(registration), [register, registration]) } export function NativeChatPaneFileDropSurface({ @@ -42,17 +59,42 @@ export function NativeChatPaneFileDropSurface({ className: string children: React.ReactNode }): React.JSX.Element { - const [registration, setRegistration] = useState(null) + const [registrations, setRegistrations] = useState([]) + const [activeScope, setActiveScope] = useState(null) + const registration = + registrations.find((entry) => entry.focusScopeKey === activeScope) ?? + registrations.find((entry) => !entry.focusScopeKey?.startsWith('queued-edit:')) ?? + registrations[0] ?? + null + const claimForTarget = useCallback( + (target: EventTarget | null): NativeChatPaneDropClaim | null => { + const scope = + target instanceof Element + ? target.closest('[data-native-chat-composer-scope]')?.dataset + .nativeChatComposerScope + : undefined + return ( + (scope + ? registrations.find((entry) => entry.focusScopeKey === scope) + : registration + )?.getClaim() ?? null + ) + }, + [registration, registrations] + ) const [isDragActive, setIsDragActive] = useState(false) const ownerRef = useRef(null) const [sequence] = useState(createOsFileDropSequence) - const attachOwner = useOsFileDropOwner(ownerRef, { + const attachOwner = useOsFileDropOwner(ownerRef, { consumer: (destination) => destination?.preparationConsumer ?? 'agent', // A paired server's chat uploads the bytes; any other owner refuses with a notice. acceptsBrowserFiles: true, sequence, - canAccept: () => Boolean(registration && !registration.getClaim().disabled), - captureDestination: () => registration?.getClaim().captureExternalDrop(), + canAccept: (event) => { + const claim = claimForTarget(event.target) + return Boolean(claim && !claim.disabled) + }, + captureDestination: (event) => claimForTarget(event.target)?.captureExternalDrop(), onDrop: async (prepared, { destination }) => { setIsDragActive(false) for (const failure of prepared.failures) { @@ -63,22 +105,26 @@ export function NativeChatPaneFileDropSurface({ } }) const register = useMemo( - () => (next) => { - setRegistration(next) - return () => { - setRegistration((current) => (current === next ? null : current)) - setIsDragActive(false) - } - }, + () => + Object.assign( + (next: NativeChatPaneDropRegistration) => { + setRegistrations((current) => [...current, next]) + return () => { + setRegistrations((current) => current.filter((entry) => entry !== next)) + setIsDragActive(false) + } + }, + { clearHover: () => setIsDragActive(false) } + ), [] ) const handlers = useMemo( () => makeNativeChatPaneFileDropHandlers({ - getClaim: () => registration?.getClaim() ?? null, + getClaim: (event) => claimForTarget(event.target ?? null), setDragActive: setIsDragActive }), - [registration] + [claimForTarget] ) // End hover even when the gesture finishes outside this pane. useLayoutEffect(() => { @@ -96,7 +142,26 @@ export function NativeChatPaneFileDropSurface({ return ( -
+
{ + 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} -