diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts index 145ea48600cf..902022ab03e7 100644 --- a/mobile/src/session/MobileNativeChatComposer.test.ts +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -1,6 +1,6 @@ -import { createElement, StrictMode, type ComponentProps } from 'react' +import { createElement, createRef, StrictMode, type ComponentProps } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { Keyboard } from 'react-native' +import { Keyboard, type TextInput } from 'react-native' import { afterEach, describe, expect, it, vi } from 'vitest' import { radii, spacing } from '../theme/mobile-theme' import { MobileNativeChatComposer as NativeChatComposer } from './MobileNativeChatComposer' @@ -284,6 +284,26 @@ describe('MobileNativeChatComposer', () => { expect(sendButton().props).toMatchObject({ disabled: true }) }) + it('hands its owner the text field, so Edit can focus it', async () => { + const focus = vi.fn() + const inputRef = createRef() + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + inputRef, + value: '', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: getCurrentSendCompletionGeneration + }), + { createNodeMock: (element) => (element.type === 'TextInput' ? { focus } : null) } + ) + }) + inputRef.current?.focus() + expect(focus).toHaveBeenCalledTimes(1) + }) + it('renders a removable thumbnail for each pending image attachment', async () => { const onRemoveAttachment = vi.fn() await act(async () => { diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx index 99764923c40b..c5d3d7759cf4 100644 --- a/mobile/src/session/MobileNativeChatComposer.tsx +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -36,6 +36,8 @@ const NO_FILE_PATHS: string[] = [] const NO_ATTACHMENTS: PendingNativeChatImage[] = [] type Props = { + /** Lets the owner focus the field, e.g. after Edit moves a queued message into it. */ + inputRef?: React.Ref structuredCommands?: readonly AgentSessionConversationCommand[] /** Controlled composer text — owned by the parent so dictation can write to it. */ value: string @@ -71,6 +73,7 @@ type Props = { } export function MobileNativeChatComposer({ + inputRef, value, onChangeText, onSend, @@ -249,6 +252,7 @@ export function MobileNativeChatComposer({ ({ })) vi.mock('./MobileNativeChatView', () => ({ MobileNativeChatView: 'ChatView' })) +vi.mock('./MobileNativeChatQueuedMessages', () => ({ MobileNativeChatQueuedMessages: 'Queued' })) function assistantTurn(id: string, text: string): NativeChatMessage { return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' } @@ -27,6 +28,7 @@ type Tick = { } function overlayElement(tick: Tick): ReturnType { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the overlay reads only these controller members; the rest of the controller is unreachable from it. const controller = { showNativeChat: tick.show ?? true, nativeChatSession: { messages: tick.messages ?? [], status: 'ready' }, @@ -38,7 +40,16 @@ function overlayElement(tick: Tick): ReturnType { chatPending: [], chatImagePreviewsByMessageId: {}, chatComposerText: '', - setChatComposerText: vi.fn() + setChatComposerText: vi.fn(), + nativeChatQueued: { + cards: [], + send: vi.fn(), + delete: vi.fn(), + edit: vi.fn(), + pause: null, + resume: vi.fn(), + sessionKey: 'session-a' + } } as unknown as MobileNativeChatController return createElement(MobileNativeChatOverlay, { controller, diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 9640608114bf..3650528d4660 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -5,6 +5,7 @@ import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data' import type { MobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments' import type { MobileNativeChatController } from './use-mobile-native-chat-controller' import { useMobileNativeChatStreamingBubble } from './use-mobile-native-chat-streaming-bubble' +import { useMobileNativeChatQueuedSlot } from './use-mobile-native-chat-queued-slot' type Props = { controller: MobileNativeChatController @@ -59,6 +60,16 @@ export function MobileNativeChatOverlay({ controller.nativeChatStreamScopeKey, controller.nativeChatStreamLive ) + const queued = controller.nativeChatQueued + const queuedSlot = useMobileNativeChatQueuedSlot({ + cards: queued.cards, + onSend: queued.send, + onDelete: queued.delete, + onEdit: queued.edit, + pause: queued.pause, + onResume: queued.resume, + sessionKey: queued.sessionKey + }) if (!controller.showNativeChat) { return null } @@ -89,6 +100,7 @@ export function MobileNativeChatOverlay({ onAnswerQuestion={controller.handleNativeChatQuestionAnswer} permission={controller.nativeChatPermission} onRespondPermission={controller.handleNativeChatRespondPermission} + queuedSlot={queuedSlot} onOpenFile={onOpenFile} hasMore={session.hasMore} loadingEarlier={session.loadingEarlier} diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx new file mode 100644 index 000000000000..b2929f1a1a57 --- /dev/null +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -0,0 +1,386 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionQueuePause } from '../../../src/shared/agent-session-wire' +import type { ActionSheetAction } from '../components/ActionSheetModal' +import { MobileNativeChatQueuedMessages } from './MobileNativeChatQueuedMessages' +import type { MobileQueuedMessageCard } from './mobile-structured-queued-message-cards' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ + AlertCircle: 'AlertCircle', + CornerDownRight: 'CornerDownRight', + ListEnd: 'ListEnd', + MoreHorizontal: 'MoreHorizontal', + Pause: 'Pause', + Pencil: 'Pencil', + Play: 'Play', + Send: 'Send', + Trash2: 'Trash2' +})) + +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) + +function card( + overrides: Partial & { messageId: string } +): MobileQueuedMessageCard { + return { + text: `text of ${overrides.messageId}`, + state: 'waiting', + paused: false, + needsAttention: false, + caption: null, + ...overrides + } +} + +describe('MobileNativeChatQueuedMessages', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function mount( + props: Parameters[0] + ): Promise { + const mounted = create(createElement('View')) + renderer = mounted + await act(async () => { + mounted.update(createElement(MobileNativeChatQueuedMessages, props)) + }) + return mounted + } + + function texts(mounted: ReactTestRenderer): unknown[] { + return mounted.root + .findAll((node) => String(node.type) === 'Text') + .map((node) => node.props.children) + } + + function nodeTypes(root: ReactTestRenderer['root']): string[] { + return root.findAll((node) => typeof node.type === 'string').map((node) => String(node.type)) + } + + function flatStyle(style: unknown): Record { + return Object.assign({}, ...[style].flat()) + } + + function sheet(mounted: ReactTestRenderer) { + return mounted.root.find((node) => String(node.type) === 'ActionSheetModal') + } + + it('draws a plain waiting card as one compact row with no caption', async () => { + const mounted = await mount({ cards: [card({ messageId: 'w', text: 'next' })] }) + const rows = mounted.root.findAllByProps({ testID: 'queued-card-row' }) + expect(rows).toHaveLength(1) + expect(texts(mounted)).toEqual(['next', 'Steer']) + // At most two lines: the row has no hover title to read the rest from. + expect( + mounted.root.find((node) => String(node.type) === 'Text' && node.props.children === 'next') + .props.numberOfLines + ).toBe(2) + expect(nodeTypes(rows[0]!)).toContain('ListEnd') + expect(nodeTypes(rows[0]!)).not.toContain('AlertCircle') + }) + + it('divides rows with hairlines inside one box, the first row undivided', async () => { + const mounted = await mount({ cards: [card({ messageId: 'a' }), card({ messageId: 'b' })] }) + const rows = mounted.root.findAllByProps({ testID: 'queued-card-row' }) + expect(rows.map((row) => Boolean(flatStyle(row.props.style).borderTopWidth))).toEqual([ + false, + true + ]) + }) + + it('captions a hold under the text on one muted line', async () => { + const mounted = await mount({ + cards: [card({ messageId: 'b', caption: 'Waiting — a message ahead needs attention' })] + }) + const caption = mounted.root.find( + (node) => + String(node.type) === 'Text' && + node.props.children === 'Waiting — a message ahead needs attention' + ) + expect(caption.props.numberOfLines).toBe(1) + expect(flatStyle(caption.props.style).color).not.toBe('#ef4444') + }) + + it("lets a returned card's reason wrap whole, in the destructive color, beside an alert", async () => { + const reason = + 'The provider did not accept this message: Claude does not support the image type .bmp in a steering message.' + const mounted = await mount({ + cards: [ + card({ messageId: 'r', state: 'returned', needsAttention: true, caption: reason }), + card({ messageId: 'w' }) + ] + }) + const caption = mounted.root.find( + (node) => String(node.type) === 'Text' && node.props.children === reason + ) + expect(caption.props.numberOfLines).toBeUndefined() + expect(flatStyle(caption.props.style).color).toBe('#ef4444') + const [returnedRow, waitingRow] = mounted.root.findAllByProps({ testID: 'queued-card-row' }) + expect(nodeTypes(returnedRow!)).toContain('AlertCircle') + expect(nodeTypes(waitingRow!)).not.toContain('AlertCircle') + }) + + it('reads Steer on a waiting card and plain Send on a returned one or one whose own send failed', async () => { + const mounted = await mount({ + cards: [ + card({ messageId: 'failed', paused: true, needsAttention: true }), + card({ messageId: 'waiting' }), + card({ messageId: 'returned', state: 'returned', needsAttention: true }) + ], + onSend: vi.fn(async () => true) + }) + expect(mounted.root.findByProps({ accessibilityLabel: 'Send this message' })).toBeTruthy() + expect( + mounted.root.findByProps({ accessibilityLabel: 'Submit without interrupting the model' }) + ).toBeTruthy() + expect(mounted.root.findByProps({ accessibilityLabel: 'Send this message again' })).toBeTruthy() + expect(texts(mounted).filter((text) => text === 'Send' || text === 'Steer')).toEqual([ + 'Send', + 'Steer', + 'Send' + ]) + const rows = mounted.root.findAllByProps({ testID: 'queued-card-row' }) + expect(nodeTypes(rows[0]!)).toContain('Send') + expect(nodeTypes(rows[1]!)).toContain('CornerDownRight') + }) + + it('deletes a card from its trash button', async () => { + const onDelete = vi.fn(async () => true) + const mounted = await mount({ cards: [card({ messageId: 'w' })], onDelete }) + const trash = mounted.root.find( + (node) => + typeof node.type === 'string' && + node.props.accessibilityLabel === 'Delete this queued message' + ) + expect(nodeTypes(trash)).toContain('Trash2') + await act(async () => trash.props.onPress()) + expect(onDelete).toHaveBeenCalledWith('w') + }) + + it('opens the card menu from "…", whose Edit message edits that card once the sheet is gone', async () => { + const onEdit = vi.fn(async () => true) + const mounted = await mount({ + cards: [card({ messageId: 'a' }), card({ messageId: 'b', text: 'fix me' })], + onEdit + }) + expect(sheet(mounted).props.visible).toBe(false) + const more = mounted.root.findAllByProps({ accessibilityLabel: 'More actions' }) + expect(more).toHaveLength(2) + await act(async () => more[1]!.props.onPress()) + expect(sheet(mounted).props.visible).toBe(true) + expect(sheet(mounted).props.title).toBe('fix me') + const actions: ActionSheetAction[] = sheet(mounted).props.actions + expect(actions.map((action) => action.label)).toEqual(['Edit message']) + // Focus can only move to the composer once iOS has dismissed the sheet's Modal. + expect(actions[0]!.closeBeforePress).toBe(true) + await act(async () => actions[0]!.onPress()) + expect(onEdit).toHaveBeenCalledWith('b') + await act(async () => sheet(mounted).props.onClose()) + expect(sheet(mounted).props.visible).toBe(false) + }) + + it('offers the card menu on a returned card, so a refused message can be fixed rather than retyped', async () => { + const onEdit = vi.fn(async () => true) + const mounted = await mount({ + cards: [card({ messageId: 'returned-1', state: 'returned', needsAttention: true })], + onEdit + }) + await act(async () => + mounted.root.findByProps({ accessibilityLabel: 'More actions' }).props.onPress() + ) + const actions: ActionSheetAction[] = sheet(mounted).props.actions + await act(async () => actions[0]!.onPress()) + expect(onEdit).toHaveBeenCalledWith('returned-1') + }) + + it("closes a card's menu for good once the card leaves, even if it comes back", async () => { + const mounted = await mount({ cards: [card({ messageId: 'a' }), card({ messageId: 'b' })] }) + await act(async () => + mounted.root.findAllByProps({ accessibilityLabel: 'More actions' })[0]!.props.onPress() + ) + expect(sheet(mounted).props.visible).toBe(true) + await act(async () => { + mounted.update( + createElement(MobileNativeChatQueuedMessages, { cards: [card({ messageId: 'b' })] }) + ) + }) + expect(sheet(mounted).props.visible).toBe(false) + // A Stop requeues a draft under its own id. + await act(async () => { + mounted.update( + createElement(MobileNativeChatQueuedMessages, { + cards: [card({ messageId: 'a' }), card({ messageId: 'b' })] + }) + ) + }) + expect(sheet(mounted).props.visible).toBe(false) + }) + + it('runs one action per card at a time, however fast its controls are tapped', async () => { + let answer: (done: boolean) => void = () => undefined + const onSend = vi.fn(() => new Promise((resolve) => (answer = resolve))) + const onDelete = vi.fn(async () => true) + const mounted = await mount({ cards: [card({ messageId: 'w' })], onSend, onDelete }) + const button = (label: string) => + mounted.root.find( + (node) => typeof node.type === 'string' && node.props.accessibilityLabel === label + ) + // All three taps land in one frame, before the disabled state can render. + await act(async () => { + button('Submit without interrupting the model').props.onPress() + button('Submit without interrupting the model').props.onPress() + button('Delete this queued message').props.onPress() + }) + expect(onSend).toHaveBeenCalledTimes(1) + expect(onDelete).not.toHaveBeenCalled() + expect(button('More actions').props.disabled).toBe(true) + await act(async () => answer(true)) + expect(button('Submit without interrupting the model').props.disabled).toBe(false) + }) + + describe('a paused queue', () => { + const waiting = card({ messageId: 'waiting-1', text: 'next' }) + + async function mountPaused( + props: Partial[0]> + ): Promise { + return mount({ cards: [waiting], onSend: vi.fn(async () => true), ...props }) + } + + it('heads the box with why the queue is paused, for each reason', async () => { + const rows: readonly [AgentSessionQueuePause['reason'], string][] = [ + ['stopped', 'Queue paused because you interrupted'], + ['restarted', 'Queue paused because Orca restarted'], + ['cleared', 'Queue paused after you cleared the conversation'] + ] + for (const [reason, label] of rows) { + const mounted = await mountPaused({ pause: { reason } }) + expect(texts(mounted)).toContain(label) + } + }) + + it("puts the pause row first in the cards' box, divided from the first card", async () => { + const mounted = await mountPaused({ pause: { reason: 'stopped' } }) + const pauseRow = mounted.root.findByProps({ testID: 'queued-pause-row' }) + const rowIds = pauseRow.parent!.children.flatMap((child) => + typeof child === 'string' ? [] : [child.props.testID] + ) + expect(rowIds).toEqual(['queued-pause-row', 'queued-card-row']) + expect(nodeTypes(pauseRow)).toEqual(expect.arrayContaining(['Pause', 'Play'])) + const [cardRow] = mounted.root.findAllByProps({ testID: 'queued-card-row' }) + expect(flatStyle(cardRow!.props.style).borderTopWidth).toBe(1) + }) + + it('shows no pause row without a pause or without cards', async () => { + expect(texts(await mountPaused({ pause: null }))).not.toContain( + 'Queue paused because you interrupted' + ) + const empty = await mountPaused({ cards: [], pause: { reason: 'stopped' } }) + expect(empty.toJSON()).toBeNull() + }) + + function resumeButton(mounted: ReactTestRenderer) { + return mounted.root.findByProps({ accessibilityLabel: 'Resume sending the queued messages' }) + } + + it('Resume asks the host to lift the pause once, however fast it is tapped twice', async () => { + let answer: (resumed: boolean) => void = () => undefined + const onResume = vi.fn(() => new Promise((resolve) => (answer = resolve))) + const mounted = await mountPaused({ pause: { reason: 'restarted' }, onResume }) + // Both taps land in one frame, before the disabled state can render. + await act(async () => { + resumeButton(mounted).props.onPress() + resumeButton(mounted).props.onPress() + }) + expect(onResume).toHaveBeenCalledTimes(1) + await act(async () => answer(true)) + }) + + it('re-enables Resume when its answer is lost, so it can be tried again', async () => { + let answer: (resumed: boolean) => void = () => undefined + const onResume = vi.fn(() => new Promise((resolve) => (answer = resolve))) + const mounted = await mountPaused({ pause: { reason: 'stopped' }, onResume }) + await act(async () => resumeButton(mounted).props.onPress()) + expect(resumeButton(mounted).props.disabled).toBe(true) + // A lost answer reads as not resumed. + await act(async () => answer(false)) + expect(resumeButton(mounted).props.disabled).toBe(false) + await act(async () => resumeButton(mounted).props.onPress()) + expect(onResume).toHaveBeenCalledTimes(2) + }) + + it('keeps every target inside its row, and announces the pause row', async () => { + const mounted = await mountPaused({ pause: { reason: 'stopped' } }) + const row = mounted.root.findByProps({ testID: 'queued-pause-row' }) + expect(row.props.accessibilityLiveRegion).toBe('polite') + // Android drops touches outside the parent: nothing may pull a row or a button past its edge. + const styled = mounted.root.findAll( + (node) => typeof node.type === 'string' && node.props.style !== undefined + ) + expect(styled.length).toBeGreaterThan(0) + for (const node of styled) { + const style = flatStyle( + typeof node.props.style === 'function' + ? node.props.style({ pressed: false }) + : node.props.style + ) + const margins = Object.entries(style).filter(([key]) => key.startsWith('margin')) + expect(margins.filter(([, value]) => typeof value === 'number' && value < 0)).toEqual([]) + } + }) + + it('keeps Steer on its cards: one card can still go beside the paused rest', async () => { + const onSend = vi.fn(async () => true) + const mounted = await mountPaused({ pause: { reason: 'stopped' }, onSend }) + const steer = mounted.root.findByProps({ + accessibilityLabel: 'Submit without interrupting the model' + }) + expect(texts(mounted)).toContain('Steer') + expect(texts(mounted)).not.toContain('Send') + await act(async () => steer.props.onPress()) + expect(onSend).toHaveBeenCalledWith('waiting-1') + }) + }) + + it('gives every action, Resume included, at least a 44pt touch target in a 44pt row', async () => { + const mounted = await mount({ + cards: [card({ messageId: 'waiting-1' })], + pause: { reason: 'stopped' }, + onSend: vi.fn(async () => true), + onDelete: vi.fn(async () => true), + onEdit: vi.fn(async () => true) + }) + const buttons = mounted.root.findAll( + (node) => typeof node.type === 'string' && node.props.accessibilityRole === 'button' + ) + expect(buttons.map((button) => button.props.accessibilityLabel)).toEqual([ + 'Resume sending the queued messages', + 'Submit without interrupting the model', + 'Delete this queued message', + 'More actions' + ]) + for (const button of buttons) { + const style = flatStyle(button.props.style({ pressed: false })) + expect(style.minHeight).toBeGreaterThanOrEqual(44) + expect(style.minWidth).toBeGreaterThanOrEqual(44) + } + for (const row of [ + mounted.root.findByProps({ testID: 'queued-pause-row' }), + ...mounted.root.findAllByProps({ testID: 'queued-card-row' }) + ]) { + expect(flatStyle(row.props.style).minHeight).toBeGreaterThanOrEqual(44) + } + }) +}) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.tsx new file mode 100644 index 000000000000..57f2fa410ffe --- /dev/null +++ b/mobile/src/session/MobileNativeChatQueuedMessages.tsx @@ -0,0 +1,293 @@ +import { useRef, useState } from 'react' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { + AlertCircle, + CornerDownRight, + ListEnd, + MoreHorizontal, + Pause, + Pencil, + Play, + Send, + Trash2 +} from 'lucide-react-native' +import { ActionSheetModal } from '../components/ActionSheetModal' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { + mobileQueuePauseLabel, + type MobileQueuedMessageCard +} from './mobile-structured-queued-message-cards' +import type { MobileQueuePause } from './mobile-structured-queued-message-feed' +import type { MobileQueuedMessageEdit } from './use-mobile-structured-queued-message-controls' + +/** The Resume row's in-flight key beside the cards' message ids, which never contain NUL. */ +const RESUME_KEY = '\u0000resume' + +export type MobileNativeChatQueuedMessagesProps = { + cards?: MobileQueuedMessageCard[] + /** Steer for a waiting card, the paused queue's included; plain Send for a card whose own send + * failed, or a returned one. */ + onSend?: (messageId: string) => Promise + onDelete?: (messageId: string) => Promise + /** Copy the card's text into the composer, then delete the card. */ + onEdit?: MobileQueuedMessageEdit + /** The whole queue's pause: the box's first row, with Resume. */ + pause?: MobileQueuePause + onResume?: () => Promise +} + +/** The host-held queued drafts, as one box of compact rows between transcript and + * composer — a queued message is never an optimistic transcript bubble. */ +export function MobileNativeChatQueuedMessages({ + cards, + onSend, + onDelete, + onEdit, + pause, + onResume +}: MobileNativeChatQueuedMessagesProps): React.JSX.Element | null { + // One in-flight action per card; a second tap must not double-consume. The ref + // closes the same-frame double tap the disabled state cannot. + const [busyIds, setBusyIds] = useState>(() => new Set()) + const inFlightRef = useRef(new Set()) + const [menuFor, setMenuFor] = useState(null) + if (!cards || cards.length === 0) { + return null + } + const run = async (messageId: string, action?: (id: string) => Promise) => { + if (inFlightRef.current.has(messageId) || !action) { + return + } + inFlightRef.current.add(messageId) + setBusyIds(new Set(inFlightRef.current)) + try { + await action(messageId) + } finally { + inFlightRef.current.delete(messageId) + setBusyIds(new Set(inFlightRef.current)) + } + } + const resuming = busyIds.has(RESUME_KEY) + // A card that drained or was removed while its menu was open closes the menu, for good: a + // Stop-requeued draft comes back under the same id and must not reopen it. + const menuCard = cards.find((card) => card.messageId === menuFor) + if (menuFor !== null && !menuCard) { + setMenuFor(null) + } + return ( + + {/* One box: the pause row, when shown, is its first row, and each card a row below it. */} + + {pause ? ( + // A polite region, as the app's other notices: React Native has no status role. + + + + {mobileQueuePauseLabel(pause)} + + [ + styles.textAction, + pressed && styles.pressed, + resuming && styles.disabled + ]} + disabled={resuming} + onPress={() => void run(RESUME_KEY, onResume && (() => onResume()))} + > + + Resume + + + ) : null} + {cards.map((card, index) => { + const busy = busyIds.has(card.messageId) + const returned = card.state === 'returned' + // "Steer" submits beside the running turn, the paused queue's cards too; a card whose + // own send failed, or a returned one, is sent again. + const steers = !returned && !card.paused + return ( + 0) && styles.divided]} + > + {card.needsAttention ? ( + + ) : ( + + )} + + {/* Two lines, not the desktop's one: the phone row has no hover title to read the rest. */} + + {card.text} + + {card.caption ? ( + // A returned card's reason only reads whole, often at its end; a hold is one line. + + {card.caption} + + ) : null} + + [ + styles.textAction, + pressed && styles.pressed, + busy && styles.disabled + ]} + disabled={busy} + onPress={() => void run(card.messageId, onSend)} + > + {steers ? ( + + ) : ( + + )} + {steers ? 'Steer' : 'Send'} + + [ + styles.iconAction, + pressed && styles.pressed, + busy && styles.disabled + ]} + disabled={busy} + onPress={() => void run(card.messageId, onDelete)} + > + + + [ + styles.iconAction, + pressed && styles.pressed, + busy && styles.disabled + ]} + disabled={busy} + onPress={() => setMenuFor(card.messageId)} + > + + + + ) + })} + + { + if (menuCard) { + void run(menuCard.messageId, onEdit) + } + } + } + ]} + onClose={() => setMenuFor(null)} + /> + + ) +} + +// Every action touches as a 44pt target (platform floor) inside its row: Android drops touches +// outside the parent, so the row is at least that tall and nothing overhangs it. +const MIN_TOUCH_TARGET = 44 + +const styles = StyleSheet.create({ + list: { + marginHorizontal: spacing.lg, + marginVertical: spacing.xs + }, + box: { + backgroundColor: colors.bgPanel, + borderRadius: radii.row, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + overflow: 'hidden' + }, + row: { + minHeight: MIN_TOUCH_TARGET, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingLeft: spacing.md + }, + divided: { + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle + }, + pauseLabel: { + flex: 1, + color: colors.textMuted, + fontSize: typography.metaSize + }, + textColumn: { + flex: 1, + minWidth: 0, + paddingVertical: spacing.sm, + gap: 2 + }, + body: { + color: colors.textPrimary, + fontSize: typography.bodySize + }, + caption: { + color: colors.textMuted, + fontSize: typography.metaSize + }, + captionReturned: { + color: colors.statusRed + }, + textAction: { + minHeight: MIN_TOUCH_TARGET, + minWidth: MIN_TOUCH_TARGET, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.sm, + borderRadius: radii.button + }, + iconAction: { + minHeight: MIN_TOUCH_TARGET, + minWidth: MIN_TOUCH_TARGET, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button + }, + actionLabel: { + color: colors.textPrimary, + fontSize: typography.metaSize, + fontWeight: '500' + }, + pressed: { + backgroundColor: colors.bgRaised + }, + disabled: { + opacity: 0.5 + } +}) diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index fa29e13c8c87..d224c2e34a0e 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -50,6 +50,7 @@ vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMess vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) vi.mock('./MobileAgentWorkingIndicator', () => ({ MobileAgentWorkingIndicator: 'WorkingIndicator' })) @@ -676,235 +677,4 @@ describe('MobileNativeChatView', () => { vi.useRealTimers() } }) - - describe('structured turn status wiring', () => { - const userTurn = (id: string, text: string): NativeChatMessage => ({ - id, - role: 'user', - blocks: [{ type: 'text', text }], - timestamp: 0, - source: 'transcript' - }) - - function rowProps(id: string): Record { - return (renderedRow(id) as { props: Record }).props - } - - function footerProps(): Record | null { - const list = renderer!.root.find((node) => node.type === 'FlatList') - const footer = list.props.ListFooterComponent as - | { props: Record } - | null - | undefined - return footer?.props ?? null - } - - function workingIndicators(): ReactTestInstance[] { - return renderer!.root.findAll((node) => node.type === 'WorkingIndicator') - } - - it('puts the clock bar under the prompt, activity at the tail, and drops the three dots', async () => { - const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'still working')] - await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true }) - const props = rowProps('u1') - expect(props.structuredActivityUi).toBe(true) - expect(props.turnStatus).toMatchObject({ workedSeconds: null }) - // Nothing reports reasoning, so the tail line reads plain working instead of guessing. - expect(footerProps()).toEqual({ thinking: false, activityText: null }) - expect(listIds().at(-1)).toBe('a1') - expect(props.activeTurnIsWorking).toBe(true) - expect(workingIndicators()).toHaveLength(0) - }) - - it.each([ - { - label: 'structured question', - cardType: 'ChatAsk', - interaction: { - ask: { - questions: [ - { - question: 'Pick destination', - multiSelect: false, - options: [{ label: 'Choice A' }, { label: 'Choice B' }] - } - ] - } - } - }, - { - label: 'question', - cardType: 'ChatQuestion', - interaction: { - question: { - question: 'Pick destination', - options: ['Choice A', 'Choice B'], - multiSelect: false, - allowOther: true, - optionTokens: ['choice-a', 'choice-b'] - } - } - }, - { - label: 'approval', - cardType: 'ChatPermission', - interaction: { - permission: { - title: 'Allow command?', - detail: 'pnpm test', - options: [ - { label: 'Allow', send: 'allow' }, - { label: 'Deny', send: 'deny' } - ] - } - } - } - ])('hides live turn activity for a pending $label without settling it', async (testCase) => { - const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'waiting for input')] - const working = { - messages: folded, - folded, - structuredActivityUi: true, - agentWorking: true, - canStop: true - } - await render({ ...working, ...testCase.interaction }) - - expect(footerProps()).toBeNull() - expect(rowProps('a1').activeTurnIsWorking).toBe(true) - expect( - renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Stop the agent') - ).toHaveLength(1) - expect(renderer!.root.findAll((node) => node.type === testCase.cardType)).toHaveLength(1) - - await update(working) - expect(footerProps()).toMatchObject({ thinking: false }) - expect(rowProps('a1').activeTurnIsWorking).toBe(true) - }) - - it('reports the live turn as thinking only when its journal says it is reasoning', async () => { - const folded = [userTurn('u1', 'go')] - await render({ - messages: folded, - folded, - structuredActivityUi: true, - agentWorking: true, - turnIndicator: { thinking: true, activityText: null } - }) - expect(rowProps('u1').turnStatus).toMatchObject({ workedSeconds: null }) - expect(footerProps()).toMatchObject({ thinking: true }) - }) - - it('hands the live row the provider activity copy that outranks its fallbacks', async () => { - const folded = [userTurn('u1', 'go')] - await render({ - messages: folded, - folded, - structuredActivityUi: true, - agentWorking: true, - turnIndicator: { thinking: true, activityText: 'Running pnpm test' } - }) - expect(footerProps()).toMatchObject({ - thinking: true, - activityText: 'Running pnpm test' - }) - }) - - it('keeps the activity copy on the live footer instead of a historical row', async () => { - const folded = [userTurn('u1', 'go'), userTurn('u2', 'again')] - await render({ - messages: folded, - folded, - structuredActivityUi: true, - agentWorking: true, - turnIndicator: { thinking: false, activityText: 'Running pnpm test' } - }) - expect(rowProps('u1')).not.toHaveProperty('turnActivityText') - expect(rowProps('u2')).not.toHaveProperty('turnActivityText') - expect(footerProps()).toMatchObject({ activityText: 'Running pnpm test' }) - }) - - it('keeps the bridge lane on the three-dot indicator with no turn status', async () => { - const folded = [userTurn('u1', 'go')] - await render({ messages: folded, folded, agentWorking: true }) - const props = rowProps('u1') - expect(props.structuredActivityUi).toBe(false) - expect(props.turnStatus).toBeNull() - expect(props.activeTurnIsWorking).toBe(false) - expect(footerProps()).toBeNull() - expect(workingIndicators()).toHaveLength(1) - }) - - it('settles the finished turn to a tappable duration', async () => { - const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'done')] - await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true }) - expect(rowProps('u1').turnStatus).toMatchObject({ workedSeconds: null }) - expect(footerProps()).toMatchObject({ thinking: false }) - await update({ messages: folded, folded, structuredActivityUi: true, agentWorking: false }) - const settled = rowProps('u1') - expect(settled.turnStatus).toMatchObject({ thinking: false }) - expect((settled.turnStatus as { workedSeconds: number | null }).workedSeconds).toBeTypeOf( - 'number' - ) - expect(settled.onToggleTurn).toBeTypeOf('function') - expect(settled.activeTurnIsWorking).toBe(false) - expect(footerProps()).toBeNull() - }) - - it('hangs no status row on an assistant row', async () => { - const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'done')] - await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true }) - expect(rowProps('a1').turnStatus).toBeNull() - // The assistant row still belongs to the live turn, so its tool row stays visible. - expect(rowProps('a1').activeTurnIsWorking).toBe(true) - expect(footerProps()).toMatchObject({ thinking: false }) - }) - - it('does not carry a running turn clock across chat surfaces', async () => { - vi.useFakeTimers() - try { - vi.setSystemTime(1_000) - const firstTab = [userTurn('u1', 'first')] - await render({ - messages: firstTab, - folded: firstTab, - structuredActivityUi: true, - agentWorking: true, - sendSurfaceId: 'host\0worktree\0tab-a' - }) - expect(rowProps('u1').turnStatus).toMatchObject({ startedAt: 1_000 }) - - vi.setSystemTime(12_000) - const secondTab = [userTurn('u2', 'second')] - await update({ - messages: secondTab, - folded: secondTab, - structuredActivityUi: true, - agentWorking: true, - sendSurfaceId: 'host\0worktree\0tab-b' - }) - - expect(rowProps('u2').turnStatus).toMatchObject({ startedAt: 12_000 }) - } finally { - vi.useRealTimers() - } - }) - - it('does not treat pre-user history as part of the live turn', async () => { - const history = [ - assistantTurn('a0', 'before the first prompt'), - userTurn('u1', 'go'), - assistantTurn('a1', 'working') - ] - await render({ - messages: history, - folded: history, - structuredActivityUi: true, - agentWorking: true - }) - - expect(rowProps('a0').activeTurnIsWorking).toBe(false) - expect(rowProps('a1').activeTurnIsWorking).toBe(true) - }) - }) }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 6247c5f6c7ca..dca95255d3a4 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -39,6 +39,7 @@ import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { MobileNativeChatComposer } from './MobileNativeChatComposer' import { MobileNativeChatPromptCard } from './MobileNativeChatPromptCard' +import { NO_QUEUED_SLOT, type MobileQueuedSlotProps } from './use-mobile-native-chat-queued-slot' import type { MobileChatPermission } from './mobile-native-chat-permission' import type { MobileChatQuestion } from './mobile-native-chat-question' import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' @@ -49,7 +50,7 @@ import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' * terminal subscription has not acknowledged its input lease yet. */ export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting' -type Props = { +type Props = MobileQueuedSlotProps & { /** Raw transcript, only for telling "still loading" from "loaded and empty". */ messages: NativeChatMessage[] /** `messages` with noise stripped and tool turns folded in, from the overlay. */ @@ -194,6 +195,7 @@ export function MobileNativeChatView({ onAnswerQuestion, permission, onRespondPermission, + queuedSlot: { cards: queuedCards, composerInputRef: inputRef } = NO_QUEUED_SLOT, onOpenFile, keyboardInset = 0 }: Props): React.JSX.Element { @@ -380,6 +382,7 @@ export function MobileNativeChatView({ ) : null} )} + {queuedCards} vi.fn()) +const scrollToOffset = vi.hoisted(() => vi.fn()) + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + FlatList: React.forwardRef((props, ref) => { + React.useImperativeHandle(ref, () => ({ scrollToEnd, scrollToOffset }), []) + return React.createElement('FlatList', props) + }), + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' + } +}) + +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) +})) + +vi.mock('react-native-gesture-handler', () => { + const chain = { + runOnJS: () => chain, + onStart: () => chain, + onUpdate: () => chain + } + return { + Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain }, + GestureDetector: 'GestureDetector', + GestureHandlerRootView: 'GestureHandlerRootView' + } +}) + +vi.mock('lucide-react-native', () => ({ + ArrowDown: 'ArrowDown', + ChevronsDownUp: 'ChevronsDownUp', + ChevronsUpDown: 'ChevronsUpDown', + Square: 'Square' +})) + +vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) +vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) +vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) +vi.mock('./MobileAgentWorkingIndicator', () => ({ + MobileAgentWorkingIndicator: 'WorkingIndicator' +})) + +// Stand-in composer: exposes the view's `handleSend` through a pressable, which is +// the only composer behaviour these banner tests exercise. +vi.mock('./MobileNativeChatComposer', async () => { + const React = await import('react') + return { + MobileNativeChatComposer: (props: { + onSend: (text: string) => Promise + disabled?: boolean + placeholder?: string + }) => + React.createElement('Composer', { + ...props, + accessibilityLabel: 'Send message', + onPress: () => props.onSend('hi') + }) + } +}) + +type Overrides = { + messages?: Parameters[0]['messages'] + folded?: Parameters[0]['folded'] + streaming?: string | null + sendErrorMessage?: string | null + onClearSendError?: () => void + inputLockReason?: 'disconnected' | 'waiting' | null + onSend?: (text: string) => Promise + pending?: Parameters[0]['pending'] + structuredActivityUi?: boolean + turnIndicator?: Parameters[0]['turnIndicator'] + agentWorking?: boolean + canStop?: boolean + ask?: Parameters[0]['ask'] + question?: Parameters[0]['question'] + permission?: Parameters[0]['permission'] + sendSurfaceId?: string + keyboardInset?: number + hasMore?: boolean + onLoadEarlier?: () => void +} + +function assistantTurn(id: string, text: string): NativeChatMessage { + return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' } +} + +function chatViewElement(overrides: Overrides): ReturnType { + return createElement(MobileNativeChatView, { + messages: [], + folded: [], + status: 'ready', + streaming: null, + onSend: vi.fn().mockResolvedValue(true), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: () => 0, + getComposerEditGeneration: () => 0, + pending: [], + composerText: '', + onComposerTextChange: vi.fn(), + ...overrides + }) +} + +describe('MobileNativeChatView', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => callback(0), 0) + ) + vi.stubGlobal('cancelAnimationFrame', (handle: ReturnType) => + clearTimeout(handle) + ) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + scrollToEnd.mockReset() + scrollToOffset.mockReset() + vi.unstubAllGlobals() + }) + + async function render(overrides: Overrides = {}): Promise { + await act(async () => { + renderer = create(chatViewElement(overrides)) + }) + } + + async function update(overrides: Overrides = {}): Promise { + await act(async () => { + renderer?.update(chatViewElement(overrides)) + }) + } + + /** Ids of the rows the list is currently rendering. */ + function listIds(): string[] { + return (list().props.data as { id: string }[]).map((row) => row.id) + } + + function list(): ReactTestInstance { + return renderer!.root.find((node) => String(node.type) === 'FlatList') + } + + function renderedRow(id: string): ReturnType { + const listNode = list() + const data = listNode.props.data as NativeChatMessage[] + const index = data.findIndex((row) => row.id === id) + return listNode.props.renderItem({ item: data[index], index }) + } + + describe('structured turn status wiring', () => { + const userTurn = (id: string, text: string): NativeChatMessage => ({ + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: 0, + source: 'transcript' + }) + + function rowProps(id: string): Record { + return (renderedRow(id) as { props: Record }).props + } + + function footerProps(): Record | null { + const list = renderer!.root.find((node) => String(node.type) === 'FlatList') + const footer = list.props.ListFooterComponent as + | { props: Record } + | null + | undefined + return footer?.props ?? null + } + + function workingIndicators(): ReactTestInstance[] { + return renderer!.root.findAll((node) => String(node.type) === 'WorkingIndicator') + } + + it('puts the clock bar under the prompt, activity at the tail, and drops the three dots', async () => { + const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'still working')] + await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true }) + const props = rowProps('u1') + expect(props.structuredActivityUi).toBe(true) + expect(props.turnStatus).toMatchObject({ workedSeconds: null }) + // Nothing reports reasoning, so the tail line reads plain working instead of guessing. + expect(footerProps()).toEqual({ thinking: false, activityText: null }) + expect(listIds().at(-1)).toBe('a1') + expect(props.activeTurnIsWorking).toBe(true) + expect(workingIndicators()).toHaveLength(0) + }) + + it.each([ + { + label: 'structured question', + cardType: 'ChatAsk', + interaction: { + ask: { + questions: [ + { + question: 'Pick destination', + multiSelect: false, + options: [{ label: 'Choice A' }, { label: 'Choice B' }] + } + ] + } + } + }, + { + label: 'question', + cardType: 'ChatQuestion', + interaction: { + question: { + question: 'Pick destination', + options: ['Choice A', 'Choice B'], + multiSelect: false, + allowOther: true, + optionTokens: ['choice-a', 'choice-b'] + } + } + }, + { + label: 'approval', + cardType: 'ChatPermission', + interaction: { + permission: { + title: 'Allow command?', + detail: 'pnpm test', + options: [ + { label: 'Allow', send: 'allow' }, + { label: 'Deny', send: 'deny' } + ] + } + } + } + ])('hides live turn activity for a pending $label without settling it', async (testCase) => { + const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'waiting for input')] + const working = { + messages: folded, + folded, + structuredActivityUi: true, + agentWorking: true, + canStop: true + } + await render({ ...working, ...testCase.interaction }) + + expect(footerProps()).toBeNull() + expect(rowProps('a1').activeTurnIsWorking).toBe(true) + expect( + renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Stop the agent') + ).toHaveLength(1) + expect(renderer!.root.findAll((node) => node.type === testCase.cardType)).toHaveLength(1) + + await update(working) + expect(footerProps()).toMatchObject({ thinking: false }) + expect(rowProps('a1').activeTurnIsWorking).toBe(true) + }) + + it('reports the live turn as thinking only when its journal says it is reasoning', async () => { + const folded = [userTurn('u1', 'go')] + await render({ + messages: folded, + folded, + structuredActivityUi: true, + agentWorking: true, + turnIndicator: { thinking: true, activityText: null } + }) + expect(rowProps('u1').turnStatus).toMatchObject({ workedSeconds: null }) + expect(footerProps()).toMatchObject({ thinking: true }) + }) + + it('hands the live row the provider activity copy that outranks its fallbacks', async () => { + const folded = [userTurn('u1', 'go')] + await render({ + messages: folded, + folded, + structuredActivityUi: true, + agentWorking: true, + turnIndicator: { thinking: true, activityText: 'Running pnpm test' } + }) + expect(footerProps()).toMatchObject({ + thinking: true, + activityText: 'Running pnpm test' + }) + }) + + it('keeps the activity copy on the live footer instead of a historical row', async () => { + const folded = [userTurn('u1', 'go'), userTurn('u2', 'again')] + await render({ + messages: folded, + folded, + structuredActivityUi: true, + agentWorking: true, + turnIndicator: { thinking: false, activityText: 'Running pnpm test' } + }) + expect(rowProps('u1')).not.toHaveProperty('turnActivityText') + expect(rowProps('u2')).not.toHaveProperty('turnActivityText') + expect(footerProps()).toMatchObject({ activityText: 'Running pnpm test' }) + }) + + it('keeps the bridge lane on the three-dot indicator with no turn status', async () => { + const folded = [userTurn('u1', 'go')] + await render({ messages: folded, folded, agentWorking: true }) + const props = rowProps('u1') + expect(props.structuredActivityUi).toBe(false) + expect(props.turnStatus).toBeNull() + expect(props.activeTurnIsWorking).toBe(false) + expect(footerProps()).toBeNull() + expect(workingIndicators()).toHaveLength(1) + }) + + it('settles the finished turn to a tappable duration', async () => { + const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'done')] + await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true }) + expect(rowProps('u1').turnStatus).toMatchObject({ workedSeconds: null }) + expect(footerProps()).toMatchObject({ thinking: false }) + await update({ messages: folded, folded, structuredActivityUi: true, agentWorking: false }) + const settled = rowProps('u1') + expect(settled.turnStatus).toMatchObject({ thinking: false }) + expect((settled.turnStatus as { workedSeconds: number | null }).workedSeconds).toBeTypeOf( + 'number' + ) + expect(settled.onToggleTurn).toBeTypeOf('function') + expect(settled.activeTurnIsWorking).toBe(false) + expect(footerProps()).toBeNull() + }) + + it('hangs no status row on an assistant row', async () => { + const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'done')] + await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true }) + expect(rowProps('a1').turnStatus).toBeNull() + // The assistant row still belongs to the live turn, so its tool row stays visible. + expect(rowProps('a1').activeTurnIsWorking).toBe(true) + expect(footerProps()).toMatchObject({ thinking: false }) + }) + + it('does not carry a running turn clock across chat surfaces', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(1_000) + const firstTab = [userTurn('u1', 'first')] + await render({ + messages: firstTab, + folded: firstTab, + structuredActivityUi: true, + agentWorking: true, + sendSurfaceId: 'host\0worktree\0tab-a' + }) + expect(rowProps('u1').turnStatus).toMatchObject({ startedAt: 1_000 }) + + vi.setSystemTime(12_000) + const secondTab = [userTurn('u2', 'second')] + await update({ + messages: secondTab, + folded: secondTab, + structuredActivityUi: true, + agentWorking: true, + sendSurfaceId: 'host\0worktree\0tab-b' + }) + + expect(rowProps('u2').turnStatus).toMatchObject({ startedAt: 12_000 }) + } finally { + vi.useRealTimers() + } + }) + + it('does not treat pre-user history as part of the live turn', async () => { + const history = [ + assistantTurn('a0', 'before the first prompt'), + userTurn('u1', 'go'), + assistantTurn('a1', 'working') + ] + await render({ + messages: history, + folded: history, + structuredActivityUi: true, + agentWorking: true + }) + + expect(rowProps('a0').activeTurnIsWorking).toBe(false) + expect(rowProps('a1').activeTurnIsWorking).toBe(true) + }) + }) +}) diff --git a/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts b/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts index c0b7fbb5fa25..518d85f2465e 100644 --- a/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts +++ b/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts @@ -39,6 +39,8 @@ vi.mock('lucide-react-native', () => ({ vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) vi.mock('./MobileNativeChatTurnStatus', () => ({ MobileNativeChatTurnActivity: 'LiveStatus' })) vi.mock('./MobileNativeChatComposer', () => ({ MobileNativeChatComposer: 'Composer' })) +// The queue's action sheet pulls in the animation runtime, which this react-native mock can't host. +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 8e4cffe158f3..3e0371805306 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -12,6 +12,7 @@ import type { } from '../../../src/shared/native-chat-turn-status' import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { MobileStructuredQueuedMessageControls } from './use-mobile-structured-queued-message-controls' import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts' import type { useMobileNativeChatSession } from './use-mobile-native-chat-session' import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' @@ -66,6 +67,9 @@ export type MobileNativeChatController = { }) => Promise handleNativeChatRespondPermission: (text: string) => Promise handleNativeChatStop: () => void + /** Host-held queued drafts shown as cards above the composer (structured lane, + * capable host only; empty otherwise). */ + nativeChatQueued: MobileStructuredQueuedMessageControls nativeChatFilePaths: string[] loadNativeChatFiles: (query: string) => void handleNativeChatQuestionAnswer: (text: string) => Promise diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index 6c2c8c0286d3..3ae08af66881 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -16,6 +16,8 @@ export type UnconfirmedSend = { text: string normalizedText: string baselineTailMessageId: string | null + /** Queued-draft cards on screen at send time; see `findQueuedUnconfirmedSends`. */ + baselineQueuedMessageIds?: readonly string[] deadline: ReturnType | null } @@ -219,6 +221,33 @@ export function findLandedImagePreviewEchoes( return landed } +/** Holds the host already took as queued-draft cards: a card not on screen at + * send time with the send's text. It will reach the transcript only when it + * drains, which can be long past the unconfirmed deadline. */ +export function findQueuedUnconfirmedSends( + cards: readonly { messageId: string; text: string }[], + entries: readonly UnconfirmedSend[] +): UnconfirmedSend[] { + if (cards.length === 0) { + return [] + } + const claimed = new Set() + const held: UnconfirmedSend[] = [] + for (const entry of entries) { + const card = cards.find( + (candidate) => + !claimed.has(candidate.messageId) && + !(entry.baselineQueuedMessageIds ?? []).includes(candidate.messageId) && + normalizeNativeChatUserText(candidate.text) === entry.normalizedText + ) + if (card) { + claimed.add(card.messageId) + held.push(entry) + } + } + return held +} + export function findLandedUnconfirmedSends( messages: readonly NativeChatMessage[], entries: readonly UnconfirmedSend[] diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts index 91bf778ba7c3..136043447899 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -22,6 +22,9 @@ export type MobileNativeChatSendOrigin = { baselineOccurrences: number baselineTailMessageId: string | null baselineResolved: boolean + /** Queued-draft cards already on screen at send time, so an earlier identical + * card cannot confirm this send. Structured lane on a queue-capable host only. */ + baselineQueuedMessageIds?: readonly string[] } type PendingByKey = Record diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index 7bc1b94f9930..2a3bc5a60b2a 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -28,8 +28,13 @@ type MobileNativeChatSendArgs = { /** 'unknown' = the RPC failed without proof the request never reached the * desktop (ack loss after a write, or a cutover that cannot tell whether the - * frame was written) — callers must not present it as a definite send failure. */ -export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown' + * frame was written) — callers must not present it as a definite send failure. + * 'queued' = structured lane only: the host holds the message as a queued + * draft, so it shows as a card above the composer, never a transcript echo. */ +export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown' | 'queued' + +/** What a terminal write can answer: the PTY lane has no draft queue. */ +export type MobileNativeChatWriteOutcome = Exclude /** Without an explicit timeout `sendRequest` waits for reconnect indefinitely, and * the composer holds `sending` (send arrow dimmed, no error) for as long as it @@ -46,7 +51,7 @@ export function openMobileNativeChatSendBudget(): number { export async function sendMobileNativeChatMessageWithOutcome( args: MobileNativeChatSendArgs -): Promise { +): Promise { const timeoutMs = args.deadline === undefined ? MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS : args.deadline - Date.now() // Starting an underfunded final write risks delivery followed by a false timeout. @@ -98,7 +103,7 @@ export async function typeMobileNativeChatCommandWithOutcome(args: { resolvedLaunchDraft?: { text: string; createdAt: number } mobileClient?: MobileTerminalClient deadline?: number -}): Promise { +}): Promise { let writeIndex = 0 return typeAgentTuiCommand({ command: args.command, diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts index 6857855c9b47..1cb211affb6e 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, - AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY + AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import { structuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' @@ -9,13 +10,17 @@ describe('structuredAgentSessionHostSupport', () => { it('reads each structured-session feature from the host capability list', () => { expect(structuredAgentSessionHostSupport([])).toEqual({ promptCancel: false, - questionAnswers: false + questionAnswers: false, + queuedMessages: false }) expect( structuredAgentSessionHostSupport([AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY]) - ).toEqual({ promptCancel: false, questionAnswers: true }) + ).toEqual({ promptCancel: false, questionAnswers: true, queuedMessages: false }) expect( structuredAgentSessionHostSupport([AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY]) - ).toEqual({ promptCancel: true, questionAnswers: false }) + ).toEqual({ promptCancel: true, questionAnswers: false, queuedMessages: false }) + expect( + structuredAgentSessionHostSupport([AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY]) + ).toEqual({ promptCancel: false, questionAnswers: false, queuedMessages: true }) }) }) 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 939f7d559f49..8994bb73cacf 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts @@ -1,12 +1,15 @@ import { AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, - AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY + AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' /** Structured-session features the connected host advertised; null until the status probe answers. */ export type StructuredAgentSessionHostSupport = { promptCancel: boolean questionAnswers: boolean + /** Mid-turn sends queue as host-held drafts; an older host keeps today's immediate path. */ + queuedMessages: boolean } export function structuredAgentSessionHostSupport( @@ -14,6 +17,7 @@ export function structuredAgentSessionHostSupport( ): StructuredAgentSessionHostSupport { return { promptCancel: capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY), - questionAnswers: capabilities.includes(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY) + questionAnswers: capabilities.includes(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY), + queuedMessages: capabilities.includes(AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY) } } diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts index acff99a6a2e1..4476b798230e 100644 --- a/mobile/src/session/mobile-structured-agent-session-rpc.ts +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -32,7 +32,10 @@ export const STRUCTURED_SEND_TIMEOUT_MS = 15_000 export type StructuredAgentSessionMutationCallResult = | { status: 'accepted'; value: TValue } | { status: 'refused'; code: AgentSessionWireRefusalCode; message: string } - | { status: 'failed'; message: string } + /** `hostRejectedByRequestSchema`: the host's schema turned this request away before running + * it, so the same request can never be accepted there. An auth refusal does not set it: + * it says nothing about an earlier delivery of the same id. */ + | { status: 'failed'; message: string; hostRejectedByRequestSchema?: true } /** `hostReportedOperationUnknown` separates a host answer about the id from doubt * about the effect. Whether that id can still be retried is the method's own * question: a plan that recovers an unknown ledger row replays or reruns it, one @@ -225,7 +228,10 @@ export async function requestStructuredAgentSessionMutation(args: { status: 'failed', message: agentSessionWriteNoticeEnglish( agentSessionWriteNoticeParts(answered, phoneWriteKind(fingerprintMethod, fields)) - ) + ), + ...(error instanceof AgentSessionRpcResponseError && error.code === 'invalid_argument' + ? { hostRejectedByRequestSchema: true } + : {}) } } if ( diff --git a/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts b/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts new file mode 100644 index 000000000000..c9da99a3cbaf --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts @@ -0,0 +1,157 @@ +// A resend past a saved record storage would not clear goes out under a fresh id. A retry of that +// text in the same app run must replay that id, never mint another: if the first resend's answer +// was lost, a second fresh id could deliver the message twice. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import type { RpcResponse } from '../transport/types' +import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' +import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) + +function ok(result: unknown): RpcResponse { + return { id: 'request-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function queuedAnswer(clientMessageId: string, state: 'waiting' | 'withdrawn'): RpcResponse { + return ok({ + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { clientMessageId, queued: { messageId: clientMessageId, position: 1, state } } + }) +} + +/** Each `agentSession.send` answered in turn: a lost answer, or a queued draft in that state. */ +function hostAnswering(answers: readonly ('lost' | 'waiting' | 'withdrawn')[]) { + const ids: string[] = [] + const deliveries: unknown[] = [] + const sendRequest = vi.fn(async (_method, params) => { + const envelope = Object(Object(params).envelope) + const id = String(envelope.clientOperationId) + const answer = answers[ids.length] + ids.push(id) + deliveries.push(Object(params).delivery) + if (answer === 'lost' || answer === undefined) { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + return queuedAnswer(id, answer) + }) + const client: RpcClient = { + sendRequest, + subscribe: vi.fn(() => vi.fn()), + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + return { client, ids, deliveries } +} + +function sendAgain(client: RpcClient, onError: (message: string) => void, queue = true) { + return sendMobileStructuredAgentSessionMessage({ + client, + sessionId: 'session-1', + sessionKey: 'host-a:session-1', + callerIdentity: 'device-a', + expectedRuntimeFence: 3, + text: 'again', + attachments: [], + ...(queue ? { delivery: 'queue-if-active' as const } : {}), + onError + }) +} + +describe('a resend past a saved record storage would not clear', () => { + let stored: Map + + beforeEach(() => { + vi.clearAllMocks() + resetMobileStructuredSendOperationJournalForTests() + stored = new Map() + asyncStorage.getItem.mockImplementation(async (key: string) => stored.get(key) ?? null) + asyncStorage.setItem.mockImplementation(async (key: string, value: string) => { + stored.set(key, value) + }) + asyncStorage.removeItem.mockImplementation(async (key: string) => { + stored.delete(key) + }) + }) + + it('replays its own id once storage recovers, even without the queue capability now', async () => { + const { client, ids, deliveries } = hostAnswering([ + 'lost', + 'withdrawn', + 'lost', + 'withdrawn', + 'waiting' + ]) + const onError = vi.fn() + expect(await sendAgain(client, onError)).toBe('unknown') + asyncStorage.setItem.mockRejectedValue(new Error('disk full')) + asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) + expect(await sendAgain(client, onError)).toBe('unknown') + // Storage recovers: the lost send's record now clears, and the queue capability is gone. + asyncStorage.setItem.mockImplementation(async (key: string, value: string) => { + stored.set(key, value) + }) + asyncStorage.removeItem.mockImplementation(async (key: string) => { + stored.delete(key) + }) + expect(await sendAgain(client, onError, false)).toBe('queued') + expect(ids).toHaveLength(5) + // The resend's id is replayed, as first sent, never replaced by a fresh one. + expect(ids[4]).toBe(ids[2]) + expect(deliveries[4]).toBe('queue-if-active') + }) + + it('replays its own id on a retry after a lost answer, and reports the record only once sent', async () => { + // Lost first send; its withdrawn replay; the fresh resend's answer lost; the retry's + // withdrawn replay; then the resend answered. + const { client, ids } = hostAnswering(['lost', 'withdrawn', 'lost', 'withdrawn', 'waiting']) + const onError = vi.fn() + const send = () => + sendMobileStructuredAgentSessionMessage({ + client, + sessionId: 'session-1', + sessionKey: 'host-a:session-1', + callerIdentity: 'device-a', + expectedRuntimeFence: 3, + text: 'again', + attachments: [], + delivery: 'queue-if-active', + onError + }) + expect(await send()).toBe('unknown') + // From here the lost send's record can never be cleared. + asyncStorage.setItem.mockRejectedValue(new Error('disk full')) + asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) + + expect(await send()).toBe('unknown') + // An unconfirmed resend may not have gone out, so nothing says it was sent. + expect(onError).not.toHaveBeenCalled() + + expect(await send()).toBe('queued') + expect(ids).toHaveLength(5) + expect(ids[1]).toBe(ids[0]) + expect(ids[3]).toBe(ids[0]) + expect(ids[2]).not.toBe(ids[0]) + // The retry replays the resend's id rather than minting one that could deliver twice. + expect(ids[4]).toBe(ids[2]) + expect(onError).toHaveBeenCalledWith( + "Sent, but this phone couldn't update its record of sent messages." + ) + }) +}) diff --git a/mobile/src/session/mobile-structured-agent-session-send.ts b/mobile/src/session/mobile-structured-agent-session-send.ts index 82d237d4d4aa..ce7f60a8b929 100644 --- a/mobile/src/session/mobile-structured-agent-session-send.ts +++ b/mobile/src/session/mobile-structured-agent-session-send.ts @@ -16,9 +16,12 @@ import { import { structuredSessionOperationId } from './structured-session-operation-id' import { mobileStructuredSendDelivery } from './mobile-structured-send-delivery' import { + bypassedMobileStructuredSendOperationId, clearMobileStructuredSendOperation, + forgetBypassedMobileStructuredSendOperation, getOrCreateMobileStructuredSendOperation, - mobileStructuredSendOperationKey + mobileStructuredSendOperationKey, + rememberBypassedMobileStructuredSendOperation } from './mobile-structured-send-operation-journal' export async function sendMobileStructuredAgentSessionMessage(input: { @@ -29,8 +32,19 @@ export async function sendMobileStructuredAgentSessionMessage(input: { expectedRuntimeFence: number text: string attachments: readonly (StructuredAgentSessionAttachment & { contentFingerprint?: string })[] + /** Sent only when the host advertises `agent-session.queued-messages.v1`. */ + delivery?: 'queue-if-active' deadline?: number onError: (message: string) => void + /** Internal: the one fresh-id resend after a withdrawn replay. */ + resendingAfterWithdrawal?: true + /** Internal: that resend when the withdrawn id's record could not be cleared. It bypasses the + * record, so failing storage never blocks the send; its id is kept in memory for this app run, + * so a retry after a lost answer replays it rather than sending again. */ + bypassRetainedRecord?: true + /** Internal: on that resend, the key the withdrawn record matched. The remembered id is keyed + * by it, so a capability change since the lost answer never mints another id. */ + resendOperationKey?: string }): Promise { const timeoutMs = timeoutForDeadline(input.deadline) if (timeoutMs === null) { @@ -43,39 +57,68 @@ export async function sendMobileStructuredAgentSessionMessage(input: { sessionId: input.sessionId, fields: { body: requestedBody } }) - const intentFingerprint = structuredAgentSessionDomainFingerprint({ - domain: 'mobile.agentSession.send.intent', - sessionId: input.sessionKey, - fields: { - text: input.text.trimEnd(), - attachments: input.attachments.map( - (attachment) => - attachment.contentFingerprint ?? - structuredAgentSessionDomainFingerprint({ - domain: 'mobile.nativeChat.image.preview', - sessionId: '', - fields: { previewUri: attachment.previewUri } - }) - ) - } - }) - const operationKey = mobileStructuredSendOperationKey({ - sessionKey: input.sessionKey, - intentFingerprint - }) + const intentFields = { + text: input.text.trimEnd(), + attachments: input.attachments.map( + (attachment) => + attachment.contentFingerprint ?? + structuredAgentSessionDomainFingerprint({ + domain: 'mobile.nativeChat.image.preview', + sessionId: '', + fields: { previewUri: attachment.previewUri } + }) + ) + } + // `delivery` is part of the intent key, never a stored journal field: the + // immediate key is exactly today's, so an older build still reads the journal. + const operationKeyFor = (delivery: 'queue-if-active' | undefined): string => + mobileStructuredSendOperationKey({ + sessionKey: input.sessionKey, + intentFingerprint: structuredAgentSessionDomainFingerprint({ + domain: 'mobile.agentSession.send.intent', + sessionId: input.sessionKey, + fields: delivery ? { ...intentFields, delivery } : intentFields + }) + }) + const queuedOperationKey = operationKeyFor('queue-if-active') + const immediateOperationKey = operationKeyFor(undefined) + const requestedOperationKey = input.delivery ? queuedOperationKey : immediateOperationKey + const attachmentPaths = input.attachments.map((attachment) => attachment.path) + const resendKey = input.resendOperationKey ?? requestedOperationKey let operation: Awaited> try { - operation = await getOrCreateMobileStructuredSendOperation({ - operationKey, - callerIdentity: input.callerIdentity, - payloadFingerprint: requestedPayloadFingerprint, - attachmentPaths: input.attachments.map((attachment) => attachment.path), - createOperationId: structuredSessionOperationId - }) + if (input.bypassRetainedRecord) { + operation = bypassOperation(resendKey, requestedPayloadFingerprint, attachmentPaths) + } else if ( + input.resendOperationKey !== undefined && + bypassedMobileStructuredSendOperationId(resendKey) !== undefined + ) { + // Storage recovered after a bypassed resend's answer was lost: that id is replayed, never + // replaced by a fresh one that could deliver the message twice. + operation = await adoptBypassedOperation({ + operationKey: resendKey, + callerIdentity: input.callerIdentity, + payloadFingerprint: requestedPayloadFingerprint, + attachmentPaths + }) + } else { + operation = await getOrCreateMobileStructuredSendOperation({ + operationKey: requestedOperationKey, + // A retained id replays exactly as first sent, whatever the capability says now; + // a host that refuses that request shape retires it, so the next send goes out fresh. + alternateOperationKey: input.delivery ? immediateOperationKey : queuedOperationKey, + callerIdentity: input.callerIdentity, + payloadFingerprint: requestedPayloadFingerprint, + attachmentPaths, + createOperationId: structuredSessionOperationId + }) + } } catch { input.onError('Message not sent') return 'rejected' } + const operationKey = operation.operationKey + const delivery = operationKey === queuedOperationKey ? 'queue-if-active' : undefined const body = structuredAgentSessionSendBody( input.text, operation.attachmentPaths.map((path) => ({ path, previewUri: '' })) @@ -95,24 +138,105 @@ export async function sendMobileStructuredAgentSessionMessage(input: { fingerprintMethod: 'agentSession.send', sessionId: input.sessionId, expectedRuntimeFence: input.expectedRuntimeFence, - fields: { body }, + // `delivery` joins the wire fields — and so the operation fingerprint — but + // never the journal's body-only fingerprint the submission echo recomputes. + fields: { body, ...(delivery ? { delivery } : {}) }, clientOperationId: operation.operationId, timeoutMs }) - const delivery = mobileStructuredSendDelivery(result, operation.retained) - if (delivery.operationIdSpent) { + const outcome = mobileStructuredSendDelivery(result, operation.retained) + if (input.bypassRetainedRecord && outcome.operationIdSpent) { + forgetBypassedMobileStructuredSendOperation(operationKey, operation.operationId) + } + let released = false + if (outcome.operationIdSpent) { try { await clearMobileStructuredSendOperation({ operationKey, operationId: operation.operationId }) + released = true } catch { // A retained settled id can suppress a later identical send, never // duplicate this one; the next replay gets another clear chance. } } - if (delivery.error !== null) { - input.onError(delivery.error) + const withdrawnReplay = + result.status === 'accepted' && + 'queued' in result.value && + result.value.queued?.state === 'withdrawn' + if (withdrawnReplay && operation.retained && !input.resendingAfterWithdrawal) { + // The retained id's draft was withdrawn, so it never reached the agent: + // this identical message is a new one, not a replay to swallow. A record + // storage would not clear is bookkeeping: it is reported, never allowed to + // block the send. + const resent = await sendMobileStructuredAgentSessionMessage({ + ...input, + resendingAfterWithdrawal: true, + resendOperationKey: operationKey, + ...(released ? {} : { bypassRetainedRecord: true as const }) + }) + // Only when the resend is known to have gone out; an unconfirmed one may not have. + if (!released && (resent === 'accepted' || resent === 'queued')) { + input.onError("Sent, but this phone couldn't update its record of sent messages.") + } + return resent + } + if (withdrawnReplay) { + // Not resent: no card and no bubble holds the text, so it goes back to the + // composer rather than vanishing. + input.onError('Message not sent') + return 'rejected' + } + if (outcome.error !== null) { + input.onError(outcome.error) + } + return outcome.outcome +} + +/** The id a resend past an uncleared record goes out under: the one this app run already used + * for this text, replayed, or a fresh one remembered for the next retry. */ +function bypassOperation( + operationKey: string, + payloadFingerprint: string, + attachmentPaths: string[] +): Awaited> { + const bypassed = bypassedMobileStructuredSendOperationId(operationKey) + const operationId = bypassed ?? structuredSessionOperationId() + if (bypassed === undefined) { + rememberBypassedMobileStructuredSendOperation(operationKey, operationId) + } + return { + operationKey, + operationId, + retained: bypassed !== undefined, + payloadFingerprint, + attachmentPaths + } +} + +/** A remembered bypassed id handed back to the saved record once storage works again: the record + * replays it, and memory lets it go. Should storage fail again, memory keeps replaying it. */ +async function adoptBypassedOperation(input: { + operationKey: string + callerIdentity: string + payloadFingerprint: string + attachmentPaths: string[] +}): Promise>> { + const operationId = bypassedMobileStructuredSendOperationId(input.operationKey) + if (operationId === undefined) { + throw new Error('No bypassed send id to adopt') + } + try { + const recorded = await getOrCreateMobileStructuredSendOperation({ + ...input, + createOperationId: () => operationId + }) + if (recorded.operationId === operationId) { + forgetBypassedMobileStructuredSendOperation(input.operationKey, operationId) + } + return { ...recorded, retained: true } + } catch { + return bypassOperation(input.operationKey, input.payloadFingerprint, input.attachmentPaths) } - return delivery.outcome } diff --git a/mobile/src/session/mobile-structured-composer-command.test.ts b/mobile/src/session/mobile-structured-composer-command.test.ts index d37e337f0913..41a8a986b1ca 100644 --- a/mobile/src/session/mobile-structured-composer-command.test.ts +++ b/mobile/src/session/mobile-structured-composer-command.test.ts @@ -1,9 +1,20 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) + import { dispatchMobileStructuredCommand } from './mobile-structured-composer-command' function setup() { - const sendRequest = vi.fn(async (_method: string, _params: unknown, _options: unknown) => ({ + const sendRequest = vi.fn< + (method: string, params: unknown, options: unknown) => Promise + >(async () => ({ ok: true, result: { ok: true, value: { command: 'compact', state: 'completed' } } })) @@ -29,7 +40,17 @@ function setup() { } return { input, sendRequest } } +/** The fields one recorded request carried, read without asserting their shape. */ +function requestFields(call: readonly unknown[] | undefined): Record { + const params = call?.[1] + return typeof params === 'object' && params !== null + ? Object.fromEntries(Object.entries(params)) + : {} +} describe('mobile structured conversation commands', () => { + beforeEach(() => { + vi.clearAllMocks() + }) it.each(['/clear', '/compact'])( 'uses the command RPC for %s without an ordinary send', async (text) => { @@ -97,6 +118,16 @@ describe('mobile structured conversation commands', () => { expect(input.onError).toHaveBeenCalled() } ) + it('/clear is a plain command on every host: no withdrawal fields, nothing persisted', async () => { + // The host carries queued cards to the replacement session itself; the + // client asks for nothing back and has nothing to restore. + const { input, sendRequest } = setup() + expect(await dispatchMobileStructuredCommand({ ...input, text: '/clear' })).toBe('accepted') + const fields = requestFields(sendRequest.mock.calls[0]) + expect(Object.keys(fields).sort()).toEqual(['command', 'envelope']) + expect(fields.command).toBe('clear') + expect(asyncStorage.setItem).not.toHaveBeenCalled() + }) it('keeps ordinary messages on the existing send path', async () => { const { input, sendRequest } = setup() expect(await dispatchMobileStructuredCommand({ ...input, text: 'hello' })).toBeNull() diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts new file mode 100644 index 000000000000..2c6173b54a5a --- /dev/null +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../src/shared/structured-agent-session-dispatch-rejection' +import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../src/shared/agent-session-wire' +import type { AgentSessionQueuedMessage } from '../../../src/shared/agent-session-wire' +import { + mobileQueueHasResumableCard, + mobileQueuePauseLabel, + mobileQueuedMessageCards +} from './mobile-structured-queued-message-cards' + +/** A returned card as the host publishes it: the refusal's sentence and its typed fact. */ +function returnedAs(fact: Parameters[0]) { + const { reason, rejection } = agentSessionFailureWords(fact, { surface: 'rejection' }) + return { state: 'returned' as const, returnedReason: reason, returnedRejection: rejection } +} + +function draft(overrides: Partial & { messageId: string }) { + return { + position: 1, + body: { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: `body of ${overrides.messageId}` }] + }, + state: 'waiting' as const, + ...overrides + } +} + +describe('mobileQueuedMessageCards', () => { + it('renders nothing without a published list', () => { + expect(mobileQueuedMessageCards(null, [], { pendingPrompt: false })).toEqual([]) + expect(mobileQueuedMessageCards([], [], { pendingPrompt: false })).toEqual([]) + }) + + it('captions nothing on a plain waiting draft, as the desktop row', () => { + const [card] = mobileQueuedMessageCards([draft({ messageId: 'a' })], [], { + pendingPrompt: false + }) + expect(card).toEqual({ + messageId: 'a', + text: 'body of a', + state: 'waiting', + paused: false, + needsAttention: false, + caption: null + }) + }) + + it('captions a waiting draft behind a pending prompt', () => { + const [card] = mobileQueuedMessageCards([draft({ messageId: 'a' })], [], { + pendingPrompt: true + }) + expect(card?.caption).toBe('Waiting for your answer') + }) + + it('captions nothing on a waiting card of a paused queue; its pause row explains', () => { + const [card] = mobileQueuedMessageCards([draft({ messageId: 'a' })], [], { + pendingPrompt: false, + queuePaused: true + }) + expect(card?.caption).toBeNull() + expect(card?.paused).toBe(false) + expect(card?.needsAttention).toBe(false) + }) + + it("keeps a card's own failed send and reads a prompt's wait as queued under a paused queue", () => { + const cards = mobileQueuedMessageCards( + [ + draft({ messageId: 'a', paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED }), + draft({ messageId: 'b', position: 2 }) + ], + [], + { pendingPrompt: true, queuePaused: true } + ) + expect(cards.map((card) => card.caption)).toEqual(["Couldn't send — tap Send to retry", null]) + expect(cards[0]?.paused).toBe(true) + }) + + it('finds something for Resume to send only in a waiting card with no hold, ahead of a returned one', () => { + const resumable = (drafts: AgentSessionQueuedMessage[]) => + mobileQueueHasResumableCard( + mobileQueuedMessageCards(drafts, [], { pendingPrompt: false, queuePaused: true }) + ) + const returned = draft({ + messageId: 'r', + ...returnedAs(agentSessionFailureFact('hostRestarted')) + }) + const failed = draft({ + messageId: 'f', + paused: true, + pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED + }) + const waiting = draft({ messageId: 'w', position: 2 }) + expect(resumable([returned])).toBe(false) + expect(resumable([returned, waiting])).toBe(false) + expect(resumable([failed])).toBe(false) + expect(resumable([failed, waiting])).toBe(true) + expect(resumable([waiting, { ...returned, position: 3 }])).toBe(true) + }) + + it('words the paused queue by reason, and one this build does not know as a plain pause', () => { + expect(mobileQueuePauseLabel({ reason: 'stopped' })).toBe( + 'Queue paused because you interrupted' + ) + expect(mobileQueuePauseLabel({ reason: 'restarted' })).toBe( + 'Queue paused because Orca restarted' + ) + expect(mobileQueuePauseLabel({ reason: 'cleared' })).toBe( + 'Queue paused after you cleared the conversation' + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a reason newer than this build's union, as a newer host would send it. + const newer = { reason: 'later_reason' } as never + expect(mobileQueuePauseLabel(newer)).toBe('Queue paused') + }) + + it('captions a reasonless pause as a plain pause, promising no release rule', () => { + const [card] = mobileQueuedMessageCards([draft({ messageId: 'a', paused: true })], [], { + pendingPrompt: false + }) + expect(card?.caption).toBe('Paused') + }) + + it("shows a returned card with the provider's own words and holds drafts behind it", () => { + const refused = agentSessionFailureFact('providerRejected', { + detail: { text: 'Steering is unavailable', audience: 'person' } + }) + const cards = mobileQueuedMessageCards( + [draft({ messageId: 'a', ...returnedAs(refused) }), draft({ messageId: 'b', position: 2 })], + [], + { pendingPrompt: false } + ) + expect(cards[0]?.caption).toContain('Steering is unavailable') + expect(cards[0]?.state).toBe('returned') + expect(cards[0]?.needsAttention).toBe(true) + expect(cards[1]?.caption).toBe('Waiting — a message ahead needs attention') + expect(cards[1]?.needsAttention).toBe(false) + }) + + it('hides a waiting card once its hand-off arrived, never a returned one', () => { + const cards = mobileQueuedMessageCards( + [ + draft({ messageId: 'returned', ...returnedAs(agentSessionFailureFact('hostRestarted')) }), + draft({ messageId: 'drained', position: 2 }), + draft({ messageId: 'waiting', position: 3 }) + ], + [ + { queuedMessageId: 'returned', dispatchState: 'rejected' }, + { queuedMessageId: 'drained', dispatchState: 'pending' } + ], + { pendingPrompt: false } + ) + expect(cards.map((card) => card.messageId)).toEqual(['returned', 'waiting']) + expect(cards[1]?.caption).toBe('Waiting — a message ahead needs attention') + }) + + it('shows a draft a Stop requeued beside its rejected hand-off', () => { + // The requeued draft keeps its id; its withdrawn hand-off stays in the journal as rejected and + // the transcript hides it, so hiding the card too would leave the text unreachable. + const cards = mobileQueuedMessageCards( + [ + draft({ messageId: 'requeued' }), + draft({ messageId: 'sending', position: 2 }), + draft({ messageId: 'sent', position: 3 }) + ], + [ + { queuedMessageId: 'requeued', dispatchState: 'rejected' }, + { queuedMessageId: 'sending', dispatchState: 'pending' }, + { queuedMessageId: 'sent', dispatchState: 'accepted' } + ], + { pendingPrompt: false } + ) + expect(cards.map((card) => card.messageId)).toEqual(['requeued']) + }) + + it('maps a Stop-withdrawn returned card to its own English copy', () => { + const [card] = mobileQueuedMessageCards( + [draft({ messageId: 'a', ...returnedAs(agentSessionFailureFact('cancelled')) })], + [], + { pendingPrompt: false } + ) + expect(card?.caption).toBe('Stopped before it was sent') + }) + + it('reads a Stop withdrawal from the fact, whatever sentence rides beside it', () => { + const [card] = mobileQueuedMessageCards( + [ + draft({ + messageId: 'a', + state: 'returned', + returnedReason: 'This message was withdrawn before the agent started it.', + returnedRejection: { kind: 'cancelled' } + }) + ], + [], + { pendingPrompt: false } + ) + expect(card?.caption).toBe('Stopped before it was sent') + }) + + it('words a host-restart returned card from its fact, as a rejected send', () => { + const [card] = mobileQueuedMessageCards( + [draft({ messageId: 'a', ...returnedAs(agentSessionFailureFact('hostRestarted')) })], + [], + { pendingPrompt: false } + ) + expect(card?.caption).toBe('Orca restarted before this message was sent.') + }) + + it("keeps a provider's log-only detail off the card", () => { + const refused = agentSessionFailureFact('providerRejected', { + detail: { text: 'stack trace for the log', audience: 'log' } + }) + const [card] = mobileQueuedMessageCards( + [draft({ messageId: 'a', ...returnedAs(refused) })], + [], + { + pendingPrompt: false + } + ) + expect(card?.caption).not.toContain('stack trace') + }) + + it("words a fact kind this build cannot place with the host's sentence", () => { + const [card] = mobileQueuedMessageCards( + [ + draft({ + messageId: 'a', + state: 'returned', + returnedReason: 'Words for a kind a newer host added.', + returnedRejection: { kind: 'laterKind' } + }) + ], + [], + { pendingPrompt: false } + ) + expect(card?.caption).toBe('Words for a kind a newer host added.') + }) + + it('maps the send-failed pause marker to English and an unknown marker to a plain pause', () => { + const cards = mobileQueuedMessageCards( + [ + draft({ messageId: 'a', paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED }), + // SAFETY: a newer host's marker this build has no vocabulary for. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: simulates a marker from a newer host than this build's union. + draft({ messageId: 'b', position: 2, paused: true, pausedReason: 'later_marker' as never }) + ], + [], + { pendingPrompt: false } + ) + expect(cards.map((card) => card.caption)).toEqual([ + "Couldn't send — tap Send to retry", + 'Paused' + ]) + // Only a failed send alerts; a plain pause is not the card's fault. + expect(cards.map((card) => card.needsAttention)).toEqual([true, false]) + }) + + it('never shows an internal rejection reason verbatim, even from a host that wrote no fact', () => { + const [card] = mobileQueuedMessageCards( + [ + draft({ + messageId: 'a', + state: 'returned', + returnedReason: DISPATCH_REJECTED_HOST_RESTARTED + }) + ], + [], + { pendingPrompt: false } + ) + expect(card?.caption).toBe('Your message was not sent.') + }) +}) diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts new file mode 100644 index 000000000000..16393dbbf8ba --- /dev/null +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -0,0 +1,141 @@ +// Render models for the host-held queued drafts shown above the composer. +// The wire carries no hold copy on purpose: the caption is derived here from the +// draft's own state plus the live facts the client already holds. + +import { readAgentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' +import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' +import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' +import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/structured-agent-session-send-disposition' +import { + QUEUED_MESSAGE_PAUSED_SEND_FAILED, + type AgentSessionQueuedMessage, + type AgentSessionQueuePause +} from '../../../src/shared/agent-session-wire' + +export type MobileQueuedMessageCard = { + messageId: string + /** The draft's text blocks joined for display and for Edit's composer copy. */ + text: string + state: 'waiting' | 'returned' + paused: boolean + /** Returned, or its own send failed: the row leads with an alert. */ + needsAttention: boolean + /** Status under the text; null for a card plainly waiting its turn, the paused queue's too. */ + caption: string | null +} + +function queuedMessageBodyText(body: AgentSessionQueuedMessage['body']): string { + return body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') +} + +function returnedCaption( + draft: Pick +): string { + const reason = draft.returnedReason ?? null + const rejection = draft.returnedRejection + if (dispatchWasWithdrawn({ dispatchState: 'rejected', reason, rejection })) { + return 'Stopped before it was sent' + } + // Worded as the desktop card words it: the fact decides, the reason is the fallback, and the + // card's own Send is the retry, so the words leave out sending again. + return agentSessionWriteNoticeEnglish( + structuredAgentSessionAttemptFailureParts( + { kind: 'rejected', reason }, + { retryControl: true }, + readAgentSessionFailureFact(rejection) + ) + ) +} + +/** One card's own hold: only a failed conversion; the queue's pause is the list's first row. */ +function pausedCaption(reason: string | undefined): string { + if (reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) { + return "Couldn't send — tap Send to retry" + } + // Absent or unknown (newer host) marker: a plain pause, promising no release rule. + return 'Paused' +} + +const QUEUE_PAUSE_LABELS: Readonly> = { + stopped: 'Queue paused because you interrupted', + restarted: 'Queue paused because Orca restarted', + cleared: 'Queue paused after you cleared the conversation' +} + +/** Whether Resume would send anything: a waiting card with no hold of its own, ahead of any + * returned card. The drain stops at a returned card, so cards behind one never go. */ +export function mobileQueueHasResumableCard(cards: readonly MobileQueuedMessageCard[]): boolean { + for (const card of cards) { + if (card.state === 'returned') { + return false + } + if (!card.paused) { + return true + } + } + return false +} + +/** The paused queue's first row. A reason this build does not know (a newer host's) reads as a + * plain pause. */ +export function mobileQueuePauseLabel(pause: Pick): string { + return QUEUE_PAUSE_LABELS[pause.reason] ?? 'Queue paused' +} + +/** Cards in published order. A waiting card is hidden once a live hand-off of it arrived — a + * submission naming it as its `queuedMessageId` that was not rejected — as the desktop hides it: + * on a multi-page catch-up the shrunk list rides only the final page, so the bubble and the card + * would otherwise briefly show together. A rejected hand-off is what sent the draft back, so it + * hides nothing. Returned cards always show. */ +export function mobileQueuedMessageCards( + queuedMessages: readonly AgentSessionQueuedMessage[] | null, + submissions: readonly Pick[], + facts: { pendingPrompt: boolean; queuePaused?: boolean } +): MobileQueuedMessageCard[] { + if (!queuedMessages || queuedMessages.length === 0) { + return [] + } + const handedOff = new Set( + submissions.flatMap((submission) => + submission.queuedMessageId !== undefined && submission.dispatchState !== 'rejected' + ? [submission.queuedMessageId] + : [] + ) + ) + let behindReturned = false + const cards: MobileQueuedMessageCard[] = [] + for (const draft of queuedMessages) { + if (draft.state !== 'returned' && handedOff.has(draft.messageId)) { + continue + } + const paused = draft.paused === true + const caption = + draft.state === 'returned' + ? returnedCaption(draft) + : paused + ? pausedCaption(draft.pausedReason) + : behindReturned + ? 'Waiting — a message ahead needs attention' + : facts.queuePaused + ? // The pause row says why and offers Resume; the card promises no send time. + null + : facts.pendingPrompt + ? 'Waiting for your answer' + : null + cards.push({ + messageId: draft.messageId, + text: queuedMessageBodyText(draft.body), + state: draft.state, + paused, + needsAttention: + draft.state === 'returned' || + (paused && draft.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED), + caption + }) + if (draft.state === 'returned') { + behindReturned = true + } + } + return cards +} diff --git a/mobile/src/session/mobile-structured-queued-message-feed.test.ts b/mobile/src/session/mobile-structured-queued-message-feed.test.ts new file mode 100644 index 000000000000..3b7eb9cf15c7 --- /dev/null +++ b/mobile/src/session/mobile-structured-queued-message-feed.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import type { + AgentSessionQueuedMessage, + AgentSessionQueuePause, + AgentSessionSubscribeEvent +} from '../../../src/shared/agent-session-wire' +import { mobileQueuePauseLabel } from './mobile-structured-queued-message-cards' +import { + reduceMobileQueuePause, + reduceMobileQueuedMessageFeed +} from './mobile-structured-queued-message-feed' + +function draft(messageId: string, position: number): AgentSessionQueuedMessage { + return { + messageId, + position, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: `text ${messageId}` }] }, + state: 'waiting' + } +} + +function batch( + queuedMessages?: AgentSessionQueuedMessage[] | null, + queuePause?: AgentSessionQueuePause | null +): AgentSessionSubscribeEvent { + return { + type: 'batch', + sessionId: 'session-1', + batch: { cursor: { epoch: 'e', sequence: 1 }, items: [], removedItemIds: [], submissions: [] }, + ...(queuedMessages !== undefined ? { queuedMessages } : {}), + ...(queuePause !== undefined ? { queuePause } : {}) + } +} + +describe('reduceMobileQueuePause', () => { + it('rides with the list: a frame without the list keeps it, one with the list states it', () => { + const paused = reduceMobileQueuePause(null, batch([draft('a', 1)], { reason: 'stopped' })) + expect(paused).toEqual({ reason: 'stopped' }) + expect(reduceMobileQueuePause(paused, batch())).toBe(paused) + expect(reduceMobileQueuePause(paused, batch([draft('a', 1)], { reason: 'stopped' }))).toBe( + paused + ) + expect(reduceMobileQueuePause(paused, batch([draft('a', 1)]))).toBeNull() + }) + + it('keeps a pause that names no reason, which reads as a plain pause', () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a malformed or newer host's pause, with no reason this build can read. + const reasonless = {} as AgentSessionQueuePause + const paused = reduceMobileQueuePause(null, batch([draft('a', 1)], reasonless)) + expect(paused).toBe(reasonless) + expect(mobileQueuePauseLabel(reasonless)).toBe('Queue paused') + }) +}) + +describe('reduceMobileQueuedMessageFeed', () => { + it('holds no claim until the host publishes the field', () => { + expect(reduceMobileQueuedMessageFeed(null, batch())).toBeNull() + }) + + it('adopts a published list ordered by position', () => { + const next = reduceMobileQueuedMessageFeed(null, batch([draft('b', 2), draft('a', 1)])) + expect(next?.map((entry) => entry.messageId)).toEqual(['a', 'b']) + }) + + it('keeps the last list when a frame omits the field', () => { + const held = reduceMobileQueuedMessageFeed(null, batch([draft('a', 1)])) + expect(reduceMobileQueuedMessageFeed(held, batch())).toBe(held) + }) + + it('reads null as empty', () => { + const held = reduceMobileQueuedMessageFeed(null, batch([draft('a', 1)])) + expect(reduceMobileQueuedMessageFeed(held, batch(null))).toEqual([]) + }) + + it('never advances on the end frame', () => { + const held = reduceMobileQueuedMessageFeed(null, batch([draft('a', 1)])) + expect(reduceMobileQueuedMessageFeed(held, { type: 'end' })).toBe(held) + }) +}) diff --git a/mobile/src/session/mobile-structured-queued-message-feed.ts b/mobile/src/session/mobile-structured-queued-message-feed.ts new file mode 100644 index 000000000000..d03c08d2edd8 --- /dev/null +++ b/mobile/src/session/mobile-structured-queued-message-feed.ts @@ -0,0 +1,53 @@ +// The host publishes its queued drafts whole-list on the subscribe stream: +// present = the current list, absent = unchanged since the last frame this +// subscriber was sent, `null` = empty. History pages are ignored here — the +// live stream is authoritative and a stale history answer must never replace +// a newer live list. + +import type { + AgentSessionQueuedMessage, + AgentSessionQueuePause, + AgentSessionSubscribeEvent +} from '../../../src/shared/agent-session-wire' + +/** Null = no claim yet (older host, or nothing published on this stream). */ +export type MobileQueuedMessageFeed = AgentSessionQueuedMessage[] | null + +/** The whole queue's pause; null when it sends on its own or nothing was published. */ +export type MobileQueuePause = AgentSessionQueuePause | null + +/** The pause rides with the list: a frame that publishes the list states it, and one that omits + * the list leaves it unchanged. */ +export function reduceMobileQueuePause( + previous: MobileQueuePause, + event: AgentSessionSubscribeEvent +): MobileQueuePause { + if (event.type === 'end' || event.queuedMessages === undefined) { + return previous + } + const next = event.queuePause ?? null + // Presence first: a pause with no reason, or one this build does not know, is still a pause. + if ((next === null) !== (previous === null)) { + return next + } + return next?.reason === previous?.reason ? previous : next +} + +export function reduceMobileQueuedMessageFeed( + previous: MobileQueuedMessageFeed, + event: AgentSessionSubscribeEvent +): MobileQueuedMessageFeed { + if (event.type === 'end') { + return previous + } + if (event.queuedMessages === undefined) { + return previous + } + const list = event.queuedMessages ?? [] + if (list.length === 0 && previous !== null && previous.length === 0) { + return previous + } + // Host order is authoritative; sort defensively so card order never depends + // on publication order. + return [...list].sort((left, right) => left.position - right.position) +} diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts index 00af8a1e178e..6305bf75df57 100644 --- a/mobile/src/session/mobile-structured-send-delivery.test.ts +++ b/mobile/src/session/mobile-structured-send-delivery.test.ts @@ -39,8 +39,10 @@ describe('mobileStructuredSendDelivery', () => { } }) - it('classifies a queued draft answer as accepted and spends its id, replays included', () => { + it('classifies a queued draft answer as spent, card-rendered, never a bubble', () => { // The host holds the message now; a later identical send is a new message. + // A dispatched draft answers as `queued` only when the host lost its + // submission, so no echo would retire a bubble: it too shows nothing. for (const state of ['waiting', 'dispatched', 'returned', 'withdrawn'] as const) { const queued: StructuredAgentSessionMutationCallResult = { status: 'accepted', @@ -49,19 +51,57 @@ describe('mobileStructuredSendDelivery', () => { queued: { messageId: 'client-1', position: 1, state } } } + const outcome = 'queued' expect(mobileStructuredSendDelivery(queued)).toEqual({ - outcome: 'accepted', + outcome, operationIdSpent: true, error: null }) expect(mobileStructuredSendDelivery(queued, true)).toEqual({ - outcome: 'accepted', + outcome, operationIdSpent: true, error: null }) } }) + it("reads a replay answered by its draft's hand-off as unconfirmed, and spends the id", () => { + // The hand-off names the replayed id as its draft: the host's answer states the link, so the + // id is spent without waiting for a stream that may never carry the hand-off. + const handedOff = structuredSendResultFixture('accepted') + if (!('submission' in handedOff)) { + throw new Error('expected a submission answer') + } + const replay: StructuredAgentSessionMutationCallResult = { + status: 'accepted', + value: { + clientMessageId: 'retained-draft-id', + submission: { + ...handedOff.submission, + clientMessageId: 'fresh-id', + queuedMessageId: 'retained-draft-id' + } + } + } + expect(mobileStructuredSendDelivery(replay, true)).toEqual({ + outcome: 'unknown', + operationIdSpent: true, + error: null + }) + }) + + it('never spends a retained id on a malformed answer with no submission and no id', () => { + const malformed: StructuredAgentSessionMutationCallResult = { + status: 'accepted', + value: JSON.parse('{}') + } + expect(mobileStructuredSendDelivery(malformed, true)).toEqual({ + outcome: 'unknown', + operationIdSpent: false, + error: null + }) + }) + it('does not report a retained payload replay as a new accepted send', () => { for (const dispatchState of ['accepted', 'pending'] as const) { expect(mobileStructuredSendDelivery(accepted(dispatchState), true)).toEqual({ @@ -152,6 +192,33 @@ describe('mobileStructuredSendDelivery', () => { }) }) + it('releases a replay only when the host refuses its request shape itself', () => { + // An older host's strict schema refuses `delivery` before it runs anything: + // that replay can never be accepted, so keeping the id refuses the text forever. + expect( + mobileStructuredSendDelivery( + { + status: 'failed', + message: 'Your message was not sent. Send it again.', + hostRejectedByRequestSchema: true + }, + true + ) + ).toEqual({ + outcome: 'rejected', + operationIdSpent: true, + error: 'Your message was not sent. Send it again.' + }) + // Any other refusal (an auth failure, a host without the method) proves nothing + // about an earlier delivery of this id. + expect( + mobileStructuredSendDelivery( + { status: 'failed', message: 'Your message was not sent.' }, + true + ) + ).toMatchObject({ operationIdSpent: false }) + }) + it('fails closed when an invalid host response omits the required submission', () => { const result = { status: 'accepted', diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index 6c90158a54fb..466a0d29ff8a 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -15,7 +15,11 @@ // message is a new message and must carry a new id. // rejected — a terminal refusal or rejected submission spends a fresh id. A // pending-admission refusal, or any refusal after earlier transport doubt, -// keeps it because neither proves a retained delivery did not happen. +// keeps it because neither proves a retained delivery did not happen. The +// one exception is a host that refuses the replay's request shape itself +// (an older host's strict schema turning `delivery` away): that host can +// never accept the replay, so keeping the id would only refuse every later +// send of the same text. // unknown — the one answer that KEEPS its id, whether it came from the host or // from an ack-loss on the way back. The message may be with the provider, so // the retry has to stay a replay. Rotating here is what sent one message to a @@ -57,18 +61,28 @@ export function mobileStructuredSendDelivery( if (result.status !== 'accepted') { return { outcome: 'rejected', - operationIdSpent: !retained, + operationIdSpent: !retained || result.hostRejectedByRequestSchema === true, error: result.message } } if ('queued' in result.value && result.value.queued) { // The host holds (or already settled) the draft: the send is spent — a // later identical message is a new message. A withdrawn replay is spent - // too, never unknown: its card was deleted or carried by a /clear. - return { outcome: 'accepted', operationIdSpent: true, error: null } + // too, never unknown: its card was deleted or carried by a /clear, and the + // caller resends it or hands the text back. A dispatched draft answers here + // only when the host could not find the submission it became (a live one + // answers with that submission), so no echo would retire an optimistic + // bubble: it shows nothing, and the transcript or the card owns the text. + return { outcome: 'queued', operationIdSpent: true, error: null } } const submission: AgentJournalSubmission | undefined = 'submission' in result.value ? result.value.submission : undefined + if (submission !== undefined && submission.queuedMessageId === result.value.clientMessageId) { + // The host says this id's queued draft was handed off as that submission: the send reached + // it, so the id is spent now, not when a stream that may never carry the hand-off shows it. + // Which send the phone meant stays unconfirmed, as for any retained replay of a live send. + return { outcome: 'unknown', operationIdSpent: true, error: null } + } if (!submission || submission.dispatchState === 'unknown') { return { outcome: 'unknown', operationIdSpent: false, error: null } } diff --git a/mobile/src/session/mobile-structured-send-operation-journal.test.ts b/mobile/src/session/mobile-structured-send-operation-journal.test.ts index cee58bded950..3c76e1d1c3fc 100644 --- a/mobile/src/session/mobile-structured-send-operation-journal.test.ts +++ b/mobile/src/session/mobile-structured-send-operation-journal.test.ts @@ -187,6 +187,39 @@ describe('mobile structured send operation journal', () => { expect(values.size).toBe(0) }) + it("clears an id a draft hand-off names, whatever that hand-off's state", async () => { + const sessionKey = 'host-a:session-a' + const payloadFingerprint = '9'.repeat(64) + const operationKey = mobileStructuredSendOperationKey({ + sessionKey, + intentFingerprint: payloadFingerprint + }) + const operationId = operationIdAt(NOW, '7') + await getOrCreateMobileStructuredSendOperation({ + operationKey, + payloadFingerprint, + createOperationId: () => operationId, + now: NOW + }) + // The drain handed the draft off under a fresh id; only the link names this send. + await clearMobileStructuredSettledSendOperations({ + submissions: [ + { + clientMessageId: operationIdAt(NOW + 1, '8'), + queuedMessageId: operationId, + fence: 1, + payloadFingerprint, + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: NOW + 1, + resolvedAt: null + } + ] + }) + expect(values.size).toBe(0) + }) + it('does not clear a newer id for an older matching-payload submission', async () => { const sessionKey = 'host-a:session-a' const payloadFingerprint = 'e'.repeat(64) @@ -248,6 +281,7 @@ describe('mobile structured send operation journal', () => { now: NOW }) ).resolves.toEqual({ + operationKey: OPERATION_KEY, operationId, retained: true, payloadFingerprint, @@ -255,6 +289,30 @@ describe('mobile structured send operation journal', () => { }) }) + it('replays an entry retained under the alternate key, and says which key matched', async () => { + const operationId = operationIdAt(NOW, 'e') + const alternateKey = 'b'.repeat(64) + await getOrCreatePersistedOperation({ + operationKey: alternateKey, + callerIdentity: CALLER_IDENTITY, + payloadFingerprint: 'd'.repeat(64), + attachmentPaths: [], + createOperationId: () => operationId, + now: NOW + }) + await expect( + getOrCreatePersistedOperation({ + operationKey: OPERATION_KEY, + alternateOperationKey: alternateKey, + callerIdentity: CALLER_IDENTITY, + payloadFingerprint: 'd'.repeat(64), + attachmentPaths: [], + createOperationId: () => operationIdAt(NOW, 'f'), + now: NOW + }) + ).resolves.toMatchObject({ operationKey: alternateKey, operationId, retained: true }) + }) + it('keeps an ambiguous id after the host replay window closes', async () => { const expired = operationIdAt(NOW - AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS - 1, '6') asyncStorage.getItem.mockResolvedValueOnce( diff --git a/mobile/src/session/mobile-structured-send-operation-journal.ts b/mobile/src/session/mobile-structured-send-operation-journal.ts index 89779a2082cf..98937ad1bbbb 100644 --- a/mobile/src/session/mobile-structured-send-operation-journal.ts +++ b/mobile/src/session/mobile-structured-send-operation-journal.ts @@ -107,12 +107,16 @@ async function serialize(action: () => Promise): Promise { export async function getOrCreateMobileStructuredSendOperation(input: { operationKey: string + /** The same intent under its other delivery: a retained entry there is replayed + * as-is, so the key that matched — never a stored field — says how it was sent. */ + alternateOperationKey?: string callerIdentity: string payloadFingerprint: string attachmentPaths: readonly string[] createOperationId: () => string now?: number }): Promise<{ + operationKey: string operationId: string retained: boolean payloadFingerprint: string @@ -123,12 +127,19 @@ export async function getOrCreateMobileStructuredSendOperation(input: { const callerFingerprint = mobileStructuredSendCallerFingerprint(input.callerIdentity) const journal = parseJournal(await AsyncStorage.getItem(STORAGE_KEY)) const entries = journal.entries - const existing = entries.find((entry) => entry.operationKey === input.operationKey) + const existing = + entries.find((entry) => entry.operationKey === input.operationKey) ?? + entries.find( + (entry) => + input.alternateOperationKey !== undefined && + entry.operationKey === input.alternateOperationKey + ) if (existing) { if (existing.callerFingerprint !== callerFingerprint) { throw new Error('Structured send caller identity changed') } return { + operationKey: existing.operationKey, operationId: existing.operationId, retained: true, payloadFingerprint: existing.payloadFingerprint, @@ -153,6 +164,7 @@ export async function getOrCreateMobileStructuredSendOperation(input: { }) await writeEntries([...entries, entry]) return { + operationKey: input.operationKey, operationId, retained: false, payloadFingerprint: input.payloadFingerprint, @@ -179,7 +191,9 @@ export async function clearMobileStructuredSendOperation(input: { }) } -/** Reconcile an ack-lost operation once the authoritative journal settles it. */ +/** Reconcile an ack-lost operation once the authoritative journal settles it, or hands it off: + * a submission naming the operation's id as its `queuedMessageId` is that send's queued draft + * going out, in whatever state, so the send reached the host and its id is spent. */ export async function clearMobileStructuredSettledSendOperations(input: { submissions: readonly AgentJournalSubmission[] }): Promise { @@ -190,13 +204,20 @@ export async function clearMobileStructuredSettledSendOperations(input: { : [] ) ) - if (settled.size === 0) { + const handedOff = new Set( + input.submissions.flatMap((submission) => + submission.queuedMessageId !== undefined ? [submission.queuedMessageId] : [] + ) + ) + if (settled.size === 0 && handedOff.size === 0) { return } return serialize(async () => { const journal = parseJournal(await AsyncStorage.getItem(STORAGE_KEY)) const entries = journal.entries.filter( - (entry) => !settled.has(`${entry.payloadFingerprint}\u0000${entry.operationId}`) + (entry) => + !settled.has(`${entry.payloadFingerprint}\u0000${entry.operationId}`) && + !handedOff.has(entry.operationId) ) if (entries.length !== journal.entries.length) { await writeEntries(entries) @@ -204,7 +225,57 @@ export async function clearMobileStructuredSettledSendOperations(input: { }) } -/** Test-only: drain in-memory serialization while preserving durable storage. */ +/** + * Spend the ids the host publishes as queued drafts. A draft is named by the + * operation id of the send that created it, so seeing one is the host's own + * receipt of that send — the `queued` answer a lost acknowledgement never + * brought. Without this, a draft another device later withdraws leaves an entry + * that no submission will ever settle. + */ +export async function clearMobileStructuredQueuedSendOperations(input: { + queuedMessageIds: readonly string[] +}): Promise { + if (input.queuedMessageIds.length === 0) { + return + } + const held = new Set(input.queuedMessageIds) + return serialize(async () => { + const journal = parseJournal(await AsyncStorage.getItem(STORAGE_KEY)) + const entries = journal.entries.filter((entry) => !held.has(entry.operationId)) + if (entries.length !== journal.entries.length) { + await writeEntries(entries) + } + }) +} + +/** Ids sent past a saved record storage would not clear, keyed by the operation key that record + * matched, for this app run only: a retry of that text replays the same id instead of minting + * another, which could deliver it twice. An entry dies at app restart, when the host answers the + * id as spent, or when a saved record takes the id over; a replay the host keeps keeps it. */ +const bypassedOperationIds = new Map() + +export function bypassedMobileStructuredSendOperationId(operationKey: string): string | undefined { + return bypassedOperationIds.get(operationKey) +} + +export function rememberBypassedMobileStructuredSendOperation( + operationKey: string, + operationId: string +): void { + bypassedOperationIds.set(operationKey, operationId) +} + +export function forgetBypassedMobileStructuredSendOperation( + operationKey: string, + operationId: string +): void { + if (bypassedOperationIds.get(operationKey) === operationId) { + bypassedOperationIds.delete(operationKey) + } +} + +/** Test-only: drain in-memory serialization and bypassed ids while preserving durable storage. */ export function resetMobileStructuredSendOperationJournalForTests(): void { mutations.tail = Promise.resolve() + bypassedOperationIds.clear() } diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index 0bffa3039f24..cff91f7ab8bb 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -90,6 +90,7 @@ vi.mock('./use-mobile-structured-agent-session', () => ({ useMobileStructuredAgentSession: () => ({ session: structuredSessionState, ...structuredActivity, + queued: { cards: [], send: vi.fn(), delete: vi.fn(), edit: vi.fn() }, sendWithOutcome: structuredSendWithOutcome, cancel: structuredCancel, cancelPrompt: structuredCancelPrompt, diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 0d236770be35..3b6c1e23818f 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -82,6 +82,10 @@ export function useMobileNativeChatController(args: { nativeChatTranscriptIsLocalReadable }) + // The lane runs before the drafts hook (fixed hook order); Edit's composer + // append reaches the drafts state through this ref, set below once they exist. + // Until the drafts mount, nothing is copied, so Edit deletes nothing. + const appendComposerTextRef = useRef<(text: string) => boolean>(() => false) const { structuredSession: structuredNativeChat, session: nativeChatSession } = useMobileNativeChatSessionLane({ client, @@ -95,12 +99,15 @@ export function useMobileNativeChatController(args: { enabled: showNativeChat, connState, hostSupport: agentSessionHostSupport, - onSendError + appendComposerTextRef, + onSendError, + onActionResolved: onSendResolved }) const { composerText: chatComposerText, setComposerText: setChatComposerText, getComposerEditGeneration: getChatComposerEditGeneration, + appendComposerText, pending: chatPending, imagePreviewsByMessageId: chatImagePreviewsByMessageId, captureSendOrigin, @@ -123,7 +130,8 @@ export function useMobileNativeChatController(args: { // terminal view would permanently decline the prefill. chatActive: showNativeChat, transcriptLoading: nativeChatSession.transcriptLoading, - transcriptSettled: nativeChatSession.status === 'ready' + transcriptSettled: nativeChatSession.status === 'ready', + queuedCards: structuredNativeChat.queued.cards }) // Deliberately not gated on the chat view being visible: the streaming gate @@ -257,7 +265,8 @@ export function useMobileNativeChatController(args: { }) useLayoutEffect(() => { recordSessionOptionCommandRef.current = recordNativeChatSessionOptionCommand - }, [recordNativeChatSessionOptionCommand]) + appendComposerTextRef.current = appendComposerText + }, [appendComposerText, recordNativeChatSessionOptionCommand]) // Card actions retire the route's held failure banner too, not just sends. const answerAsk = useNativeChatAcceptedAction(handleNativeChatAnswerAsk, onSendResolved) const cancelAsk = useNativeChatAcceptedAction(handleNativeChatCancelAsk, onSendResolved) @@ -309,6 +318,8 @@ export function useMobileNativeChatController(args: { handleNativeChatCancelPrompt: activeChatStructured ? structuredCancelPrompt : undefined, handleNativeChatRespondPermission: respond, handleNativeChatStop: activeChatStructured ? structuredNativeChat.cancel : handleNativeChatStop, + // The inactive lane's session is starved of identity, so its cards stay empty. + nativeChatQueued: structuredNativeChat.queued, nativeChatFilePaths, loadNativeChatFiles, handleNativeChatQuestionAnswer: activeChatStructured diff --git a/mobile/src/session/use-mobile-native-chat-drafts-queued-hold.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-queued-hold.test.ts new file mode 100644 index 000000000000..77c95595ded9 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts-queued-hold.test.ts @@ -0,0 +1,112 @@ +// An ack-lost send the host took as a queued draft shows as a card, not in the +// transcript, until the turn ends — so the unconfirmed hold must count the card +// as delivery, or the user is told to "check chat" about a message on screen. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' + +type QueuedCard = { messageId: string; text: string } + +describe('useMobileNativeChatDrafts unconfirmed hold with queued cards', () => { + let renderer: ReactTestRenderer | null = null + let state: ReturnType | null = null + + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + state = null + vi.useRealTimers() + }) + + function Harness({ queuedCards }: { queuedCards: QueuedCard[] }): null { + state = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId: 'a', + sessionId: 'session-a', + messages: [], + launchDraft: null, + transcriptLoading: false, + transcriptSettled: true, + queuedCards + }) + return null + } + + async function render(queuedCards: QueuedCard[]): Promise { + await act(async () => { + if (renderer) { + renderer.update(createElement(Harness, { queuedCards })) + } else { + renderer = create(createElement(Harness, { queuedCards })) + } + }) + } + + /** Holds a lost send; the returned call runs out its deadline and hands back the warning spy. */ + function holdLostSend(text: string): () => ReturnType { + const onUnconfirmed = vi.fn() + const origin = state?.captureSendOrigin(text) + if (!origin) { + throw new Error('no send origin') + } + act(() => state?.holdUnconfirmedSend(origin, text, onUnconfirmed)) + return () => { + act(() => { + vi.advanceTimersByTime(30_000) + }) + return onUnconfirmed + } + } + + it("reports whether a queued card's Edit copy landed, so Edit never deletes text it did not keep", async () => { + await render([]) + let copied: boolean | undefined + act(() => { + copied = state?.appendComposerText('') + }) + expect(copied).toBe(false) + act(() => { + copied = state?.appendComposerText('edited text') + }) + expect(copied).toBe(true) + expect(state?.composerText).toBe('edited text') + }) + + it('stays quiet when the lost send appears as a queued card', async () => { + await render([]) + const settle = holdLostSend('after this turn') + await render([{ messageId: 'draft-1', text: 'after this turn' }]) + expect(settle()).not.toHaveBeenCalled() + }) + + it('stays quiet when the card was published before the lost answer returned', async () => { + await render([]) + const origin = state?.captureSendOrigin('raced') + await render([{ messageId: 'draft-1', text: 'raced' }]) + const onUnconfirmed = vi.fn() + act(() => { + if (origin) { + state?.holdUnconfirmedSend(origin, 'raced', onUnconfirmed) + } + }) + expect(vi.getTimerCount()).toBe(0) + expect(onUnconfirmed).not.toHaveBeenCalled() + }) + + it('does not confirm against an identical card already on screen at send time', async () => { + await render([{ messageId: 'earlier', text: 'same words' }]) + const settle = holdLostSend('same words') + await render([ + { messageId: 'earlier', text: 'same words' }, + { messageId: 'x', text: 'other' } + ]) + expect(settle()).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index a50ccf4c9235..f17e1826a679 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -1,14 +1,21 @@ -import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type Dispatch, + type SetStateAction +} from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { countUserTextOccurrences, findLandedImagePreviewEchoes, - findLandedUnconfirmedSends, mergeLandedImagePreviewEchoes, migrateImagePreviewMessageIds, - normalizeReconcileText, - type UnconfirmedSend + normalizeReconcileText } from './mobile-native-chat-draft-reconcile' +import { useMobileNativeChatUnconfirmedSends } from './use-mobile-native-chat-unconfirmed-sends' import { rebaseMobileNativeChatPendingBaselines } from './mobile-native-chat-pending-baseline' import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement' import { @@ -29,9 +36,6 @@ export type { MobileNativeChatPendingMessage, MobileNativeChatSendOrigin } const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = [] const NO_IMAGE_PREVIEWS: Record = {} -// Ack-lost sends wait for a transcript echo before surfacing as unconfirmed. -const UNCONFIRMED_SEND_DEADLINE_MS = 20_000 - export function useMobileNativeChatDrafts(args: { hostId: string worktreeId: string @@ -52,9 +56,14 @@ export function useMobileNativeChatDrafts(args: { * is an empty conversation, not a read that failed or never ran. Only then * does a send's captured tail describe a real boundary. */ transcriptSettled: boolean + /** The active pane's host-held queued-draft cards (structured lane only). */ + queuedCards?: readonly { messageId: string; text: string }[] }): { composerText: string setComposerText: Dispatch> + /** Append after existing typing (newline-joined); a queued card's Edit copy lands here. + * False when nothing was appended: no active draft, or no text. */ + appendComposerText: (text: string) => boolean getComposerEditGeneration: () => number pending: MobileNativeChatPendingMessage[] /** Phone-local previews rebound to the transcript message that replaced the @@ -87,7 +96,8 @@ export function useMobileNativeChatDrafts(args: { launchDraftCreatedAt, chatActive = true, transcriptLoading, - transcriptSettled + transcriptSettled, + queuedCards } = args const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId) const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null @@ -105,11 +115,11 @@ export function useMobileNativeChatDrafts(args: { const draftEditGenerationsRef = useRef(new MobileNativeChatDraftEditGenerations()) const messagesRef = useRef(messages) messagesRef.current = messages - const activeDraftKeyRef = useRef(draftKey) - activeDraftKeyRef.current = draftKey - const activePendingKeyRef = useRef(pendingKey) - activePendingKeyRef.current = pendingKey - const mountedRef = useRef(false) + const queuedCardsRef = useRef(queuedCards) + // Read only by captureSendOrigin, which runs from a send after this commit. + useLayoutEffect(() => { + queuedCardsRef.current = queuedCards + }, [queuedCards]) const { readSeededLaunchDraft, readSeededLaunchDraftSeed } = useMobileNativeChatLaunchDraftSeed({ draftKey, @@ -135,6 +145,23 @@ export function useMobileNativeChatDrafts(args: { }, [draftKey] ) + // Copied queued-card text goes after whatever is there, so newer typing is preserved. + const appendComposerText = useCallback( + (text: string): boolean => { + if (!draftKey || text.length === 0) { + return false + } + draftEditGenerationsRef.current.advance(draftKey) + setDrafts((previous) => { + const current = previous[draftKey] ?? '' + const next = current.length === 0 ? text : `${current}\n${text}` + return { ...previous, [draftKey]: next } + }) + return true + }, + [draftKey] + ) + const captureSendOrigin = useCallback( (text: string) => { if (!draftKey) { @@ -151,7 +178,10 @@ export function useMobileNativeChatDrafts(args: { // Only a settled read makes this a boundary. Anything else — hydrating, // or a read that failed — hands back an empty list that reads as "the // conversation was empty", which lets any row claim this send later. - baselineResolved: transcriptSettled + baselineResolved: transcriptSettled, + ...(queuedCardsRef.current?.length + ? { baselineQueuedMessageIds: queuedCardsRef.current.map((card) => card.messageId) } + : {}) } }, [draftKey, pendingKey, transcriptSettled] @@ -200,74 +230,12 @@ export function useMobileNativeChatDrafts(args: { [] ) - // Why: a relay drop mid-send loses only the ack in the common case — the - // desktop already delivered the message. Hold the send instead of claiming - // failure (which baits a duplicate): stay quiet when the transcript echo - // lands, and surface the uncertainty if the deadline passes without one. - // The composer was already cleared at send time, so this never touches drafts. - const unconfirmedRef = useRef([]) - const holdUnconfirmedSend = useCallback( - (origin: MobileNativeChatSendOrigin, text: string, onUnconfirmed: () => void) => { - if (!mountedRef.current) { - return - } - const isActiveTranscript = - activeDraftKeyRef.current === origin.draftKey && - (origin.pendingKey === null || activePendingKeyRef.current === origin.pendingKey) - const entry: UnconfirmedSend = { - draftKey: origin.draftKey, - pendingKey: origin.pendingKey, - text, - normalizedText: origin.normalizedText, - baselineTailMessageId: origin.baselineTailMessageId, - deadline: null - } - // Why: the transcript event can beat the lost RPC acknowledgement. - if ( - isActiveTranscript && - findLandedUnconfirmedSends(messagesRef.current, [entry]).length > 0 - ) { - return - } - entry.deadline = setTimeout(() => { - unconfirmedRef.current = unconfirmedRef.current.filter((held) => held !== entry) - onUnconfirmed() - }, UNCONFIRMED_SEND_DEADLINE_MS) - unconfirmedRef.current = [...unconfirmedRef.current, entry] - }, - [] - ) - - useEffect(() => { - if (!draftKey || unconfirmedRef.current.length === 0) { - return - } - const relevant = unconfirmedRef.current.filter( - (entry) => - entry.draftKey === draftKey && - (entry.pendingKey === null || entry.pendingKey === pendingKey) - ) - const landed = findLandedUnconfirmedSends(messages, relevant) - if (landed.length === 0) { - return - } - const landedSet = new Set(landed) - unconfirmedRef.current = unconfirmedRef.current.filter((entry) => !landedSet.has(entry)) - for (const entry of landed) { - clearTimeout(entry.deadline ?? undefined) - } - }, [messages, draftKey, pendingKey]) - - useEffect(() => { - mountedRef.current = true - return () => { - mountedRef.current = false - for (const entry of unconfirmedRef.current) { - clearTimeout(entry.deadline ?? undefined) - } - unconfirmedRef.current = [] - } - }, []) + const { holdUnconfirmedSend } = useMobileNativeChatUnconfirmedSends({ + draftKey, + pendingKey, + messages, + ...(queuedCards ? { queuedCards } : {}) + }) const waitingForSession = draftKey ? (pendingWaitingForSession[draftKey] ?? NO_PENDING_MESSAGES) @@ -336,6 +304,7 @@ export function useMobileNativeChatDrafts(args: { return { composerText: draftKey ? (drafts[draftKey] ?? '') : '', setComposerText, + appendComposerText, getComposerEditGeneration: draftEditGenerationsRef.current.readComposer, pending, imagePreviewsByMessageId: pendingKey diff --git a/mobile/src/session/use-mobile-native-chat-queued-edit-focus.test.tsx b/mobile/src/session/use-mobile-native-chat-queued-edit-focus.test.tsx new file mode 100644 index 000000000000..c2ec28b36516 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-queued-edit-focus.test.tsx @@ -0,0 +1,74 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileNativeChatQueuedEditFocus } from './use-mobile-native-chat-queued-edit-focus' +import type { MobileQueuedMessageEdit } from './use-mobile-structured-queued-message-controls' + +type Focus = ReturnType + +describe('useMobileNativeChatQueuedEditFocus', () => { + let renderer: ReactTestRenderer | null = null + let latest: Focus | null = null + + function Harness({ onEdit }: { onEdit?: MobileQueuedMessageEdit }): null { + latest = useMobileNativeChatQueuedEditFocus(onEdit) + return null + } + + beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => callback(0), 0) + ) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + latest = null + vi.unstubAllGlobals() + }) + + it('focuses the composer after Edit copies a queued message into it', async () => { + const onEdit = vi.fn(async (_messageId, onCopied) => { + onCopied?.() + return false + }) + await act(async () => { + renderer = create(createElement(Harness, { onEdit })) + }) + const focus = vi.fn() + const inputRef: { current: unknown } = latest!.composerInputRef + inputRef.current = { focus } + let edited: boolean | undefined + await act(async () => { + edited = await latest!.editQueuedMessage!('queued-1') + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(onEdit).toHaveBeenCalledWith('queued-1', expect.any(Function)) + // A failed Delete still leaves the copied text in the composer, so focus regardless. + expect(focus).toHaveBeenCalledTimes(1) + expect(edited).toBe(false) + }) + + it('leaves focus alone when Edit copied nothing', async () => { + const onEdit = vi.fn(async () => false) + await act(async () => { + renderer = create(createElement(Harness, { onEdit })) + }) + const focus = vi.fn() + const inputRef: { current: unknown } = latest!.composerInputRef + inputRef.current = { focus } + await act(async () => { + await latest!.editQueuedMessage!('queued-1') + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(focus).not.toHaveBeenCalled() + }) + + it('passes no Edit through when the cards offer none', async () => { + await act(async () => { + renderer = create(createElement(Harness, {})) + }) + expect(latest!.editQueuedMessage).toBeUndefined() + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-queued-edit-focus.ts b/mobile/src/session/use-mobile-native-chat-queued-edit-focus.ts new file mode 100644 index 000000000000..574316bccfaa --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-queued-edit-focus.ts @@ -0,0 +1,24 @@ +import { useMemo, useRef, type RefObject } from 'react' +import type { TextInput } from 'react-native' +import type { MobileQueuedMessageEdit } from './use-mobile-structured-queued-message-controls' + +/** Wraps a queued card's Edit so the composer it fills takes focus, and typing continues + * without another tap. An Edit that copied nothing leaves focus where it is. */ +export function useMobileNativeChatQueuedEditFocus(onEdit: MobileQueuedMessageEdit | undefined): { + composerInputRef: RefObject + editQueuedMessage: MobileQueuedMessageEdit | undefined +} { + const composerInputRef = useRef(null) + const editQueuedMessage = useMemo( + () => + onEdit + ? (messageId) => + onEdit(messageId, () => + // Next frame, so the copied text has landed in the field before it takes focus. + requestAnimationFrame(() => composerInputRef.current?.focus()) + ) + : undefined, + [onEdit] + ) + return { composerInputRef, editQueuedMessage } +} diff --git a/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx b/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx new file mode 100644 index 000000000000..002a567548f6 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx @@ -0,0 +1,79 @@ +// The queued-card slot keeps one card list per conversation, so an action still in flight in one +// never disables the same control in another. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useMobileNativeChatQueuedSlot } from './use-mobile-native-chat-queued-slot' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ + AlertCircle: 'AlertCircle', + CornerDownRight: 'CornerDownRight', + ListEnd: 'ListEnd', + MoreHorizontal: 'MoreHorizontal', + Pause: 'Pause', + Pencil: 'Pencil', + Play: 'Play', + Send: 'Send', + Trash2: 'Trash2' +})) + +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) + +const CARD = { + messageId: 'waiting-1', + text: 'next', + state: 'waiting' as const, + paused: false, + needsAttention: false, + caption: null +} + +function Slot({ + sessionKey, + onResume +}: { + sessionKey: string + onResume: () => Promise +}): React.JSX.Element { + const slot = useMobileNativeChatQueuedSlot({ + cards: [CARD], + pause: { reason: 'stopped' }, + onResume, + sessionKey + }) + return createElement('View', null, slot.cards) +} + +describe('useMobileNativeChatQueuedSlot', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it("never lets one conversation's pending Resume disable another's", async () => { + const pending = vi.fn(() => new Promise(() => undefined)) + const mounted = create(createElement('View')) + renderer = mounted + const resume = () => + mounted.root.findByProps({ accessibilityLabel: 'Resume sending the queued messages' }) + await act(async () => { + mounted.update(createElement(Slot, { sessionKey: 'session-a', onResume: pending })) + }) + await act(async () => resume().props.onPress()) + expect(resume().props.disabled).toBe(true) + await act(async () => { + mounted.update(createElement(Slot, { sessionKey: 'session-b', onResume: pending })) + }) + expect(resume().props.disabled).toBe(false) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-queued-slot.ts b/mobile/src/session/use-mobile-native-chat-queued-slot.ts new file mode 100644 index 000000000000..aac805a5c20e --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-queued-slot.ts @@ -0,0 +1,50 @@ +// The queued-draft cards' place in the chat view, built outside it: the cards to show between the +// transcript and the composer, and the composer ref their Edit focuses. + +import { createElement, useMemo, type ReactNode, type RefObject } from 'react' +import type { TextInput } from 'react-native' +import { + MobileNativeChatQueuedMessages, + type MobileNativeChatQueuedMessagesProps +} from './MobileNativeChatQueuedMessages' +import { useMobileNativeChatQueuedEditFocus } from './use-mobile-native-chat-queued-edit-focus' + +export type MobileNativeChatQueuedSlot = { + cards?: ReactNode + composerInputRef?: RefObject +} + +export type MobileQueuedSlotProps = { + /** Host-held queued drafts, rendered as cards between transcript and composer. */ + queuedSlot?: MobileNativeChatQueuedSlot +} + +/** A view given no slot shows no cards; one stable object, so the default never re-renders. */ +export const NO_QUEUED_SLOT: MobileNativeChatQueuedSlot = {} + +export function useMobileNativeChatQueuedSlot( + queuedMessages: MobileNativeChatQueuedMessagesProps & { + /** One card list per conversation: a pending action in one never disables another's. */ + sessionKey: string + } +): MobileNativeChatQueuedSlot { + const { composerInputRef, editQueuedMessage } = useMobileNativeChatQueuedEditFocus( + queuedMessages.onEdit + ) + const { cards, onSend, onDelete, pause, onResume, sessionKey } = queuedMessages + return useMemo( + () => ({ + cards: createElement(MobileNativeChatQueuedMessages, { + key: sessionKey, + cards, + onSend, + onDelete, + onEdit: editQueuedMessage, + pause, + onResume + }), + composerInputRef + }), + [cards, composerInputRef, editQueuedMessage, onDelete, onResume, onSend, pause, sessionKey] + ) +} 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 147cf017ac0e..c34f1995190c 100644 --- a/mobile/src/session/use-mobile-native-chat-session-lane.ts +++ b/mobile/src/session/use-mobile-native-chat-session-lane.ts @@ -1,3 +1,4 @@ +import { useCallback } from 'react' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' @@ -17,9 +18,11 @@ export function useMobileNativeChatSessionLane({ sourceIdentity, callerIdentity, hostSupport, + appendComposerTextRef, enabled, connState, - onSendError + onSendError, + onActionResolved }: { client: RpcClient | null structured: boolean @@ -32,13 +35,22 @@ export function useMobileNativeChatSessionLane({ sourceIdentity: Parameters[0]['sourceIdentity'] callerIdentity: string hostSupport: StructuredAgentSessionHostSupport | null + /** The active pane's live composer; a queued card's Edit copies through it. + * A ref because the drafts (and their append) mount after this lane. */ + appendComposerTextRef: { readonly current: (text: string) => boolean } enabled: boolean connState: ConnectionState onSendError: (message: string) => void + /** Called on any accepted queued-card action; retires the route's failure banner. */ + onActionResolved?: () => void }): { structuredSession: ReturnType session: ReturnType } { + const appendComposerText = useCallback( + (text: string) => appendComposerTextRef.current(text), + [appendComposerTextRef] + ) const bridgeSession = useMobileNativeChatSession({ client, sourceIdentity, @@ -52,12 +64,14 @@ export function useMobileNativeChatSessionLane({ sourceIdentity, callerIdentity, hostSupport, + appendComposerText, enabled, // Holds are connection-scoped; dropping this on transport loss lets the hook // reacquire the provider without clearing the cached transcript. connected: connState === 'connected', agent: structured ? agent : null, - onSendError + onSendError, + ...(onActionResolved ? { onActionResolved } : {}) }) return { structuredSession, diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx index f5cc0ff8c02e..19f0dfbc0dbe 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx @@ -6,6 +6,7 @@ import type { AgentJournalRenderItem, AgentJournalTurnScope } from '../../../src/shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' @@ -333,6 +334,48 @@ describe('useMobileNativeChatTurnDisclosure', () => { } }) + it("a queued card's Steer joins the running turn: no bar of its own, and live with it", () => { + const t1 = { kind: 'turn', turnItemId: 't1' } as const + const thread = { kind: 'thread' } as const + // Steer hands the draft over under a fresh submission id while A's turn runs, so the host + // scopes the row to that turn; the submission names the card it came from. + const steerId = agentJournalSubmissionKey('hand-off-1') + const steered = [userMessage('A'), tool, userMessage(steerId)] + render({ + messages: steered, + enabled: true, + workingStartedAt: 5_000, + turnJournal: { + items: [ + user('A', 1, thread), + turn('t1', 2, 'running', 'A', thread), + toolItem(3, t1), + user(steerId, 4, t1) + ], + submissions: [ + { + clientMessageId: 'hand-off-1', + queuedMessageId: 'draft-1', + fence: 1, + payloadFingerprint: 'fp', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 4, + resolvedAt: null + } + ] + } + }) + const disclosure = renderer!.root.findByType('result').props.disclosure + const [rowA, , rowSteer] = steered.map((message, index) => + disclosure.resolveRow(index, message) + ) + expect(rowA.turnStatus).not.toBeNull() + expect(rowSteer.turnStatus).toBeNull() + expect(rowSteer.activeTurnIsWorking).toBe(true) + }) + it('on a host that states no turn, the bar and liveness follow the running record by journal order', () => { vi.useFakeTimers() try { diff --git a/mobile/src/session/use-mobile-native-chat-unconfirmed-sends.ts b/mobile/src/session/use-mobile-native-chat-unconfirmed-sends.ts new file mode 100644 index 000000000000..f1b0e501fce5 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-unconfirmed-sends.ts @@ -0,0 +1,132 @@ +// Ack-lost sends: a relay drop mid-send usually loses only the acknowledgement — +// the desktop already delivered the message. Hold the send instead of claiming +// failure (which baits a duplicate): stay quiet when the transcript echo lands +// or the host shows it as a queued-draft card, and surface the uncertainty only +// if the deadline passes without either. The composer was already cleared at +// send time, so this never touches drafts. + +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + findLandedUnconfirmedSends, + findQueuedUnconfirmedSends, + type UnconfirmedSend +} from './mobile-native-chat-draft-reconcile' +import type { MobileNativeChatSendOrigin } from './mobile-native-chat-pending-echo' + +const UNCONFIRMED_SEND_DEADLINE_MS = 20_000 +const NO_QUEUED_CARDS: readonly QueuedCardText[] = [] + +type QueuedCardText = { messageId: string; text: string } + +function settledUnconfirmedSends( + messages: readonly NativeChatMessage[], + queuedCards: readonly QueuedCardText[], + entries: readonly UnconfirmedSend[] +): UnconfirmedSend[] { + const landed = findLandedUnconfirmedSends(messages, entries) + const landedSet = new Set(landed) + return [ + ...landed, + ...findQueuedUnconfirmedSends( + queuedCards, + entries.filter((entry) => !landedSet.has(entry)) + ) + ] +} + +export function useMobileNativeChatUnconfirmedSends(args: { + draftKey: string | null + pendingKey: string | null + messages: readonly NativeChatMessage[] + /** The active pane's queued-draft cards; a send the host holds as one is delivered. */ + queuedCards?: readonly QueuedCardText[] +}): { + holdUnconfirmedSend: ( + origin: MobileNativeChatSendOrigin, + text: string, + onUnconfirmed: () => void + ) => void +} { + const { draftKey, pendingKey, messages, queuedCards = NO_QUEUED_CARDS } = args + const messagesRef = useRef(messages) + const queuedCardsRef = useRef(queuedCards) + const activeDraftKeyRef = useRef(draftKey) + const activePendingKeyRef = useRef(pendingKey) + // Read only by the post-send hold, which runs after the commit that set them. + useLayoutEffect(() => { + messagesRef.current = messages + queuedCardsRef.current = queuedCards + activeDraftKeyRef.current = draftKey + activePendingKeyRef.current = pendingKey + }, [draftKey, messages, pendingKey, queuedCards]) + const mountedRef = useRef(false) + const unconfirmedRef = useRef([]) + const holdUnconfirmedSend = useCallback( + (origin: MobileNativeChatSendOrigin, text: string, onUnconfirmed: () => void) => { + if (!mountedRef.current) { + return + } + const isActiveTranscript = + activeDraftKeyRef.current === origin.draftKey && + (origin.pendingKey === null || activePendingKeyRef.current === origin.pendingKey) + const entry: UnconfirmedSend = { + draftKey: origin.draftKey, + pendingKey: origin.pendingKey, + text, + normalizedText: origin.normalizedText, + baselineTailMessageId: origin.baselineTailMessageId, + ...(origin.baselineQueuedMessageIds + ? { baselineQueuedMessageIds: origin.baselineQueuedMessageIds } + : {}), + deadline: null + } + // Why: the transcript event (or the card) can beat the lost RPC acknowledgement. + if ( + isActiveTranscript && + settledUnconfirmedSends(messagesRef.current, queuedCardsRef.current, [entry]).length > 0 + ) { + return + } + entry.deadline = setTimeout(() => { + unconfirmedRef.current = unconfirmedRef.current.filter((held) => held !== entry) + onUnconfirmed() + }, UNCONFIRMED_SEND_DEADLINE_MS) + unconfirmedRef.current = [...unconfirmedRef.current, entry] + }, + [] + ) + + useEffect(() => { + if (!draftKey || unconfirmedRef.current.length === 0) { + return + } + const relevant = unconfirmedRef.current.filter( + (entry) => + entry.draftKey === draftKey && + (entry.pendingKey === null || entry.pendingKey === pendingKey) + ) + const landed = settledUnconfirmedSends(messages, queuedCards, relevant) + if (landed.length === 0) { + return + } + const landedSet = new Set(landed) + unconfirmedRef.current = unconfirmedRef.current.filter((entry) => !landedSet.has(entry)) + for (const entry of landed) { + clearTimeout(entry.deadline ?? undefined) + } + }, [messages, queuedCards, draftKey, pendingKey]) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + for (const entry of unconfirmedRef.current) { + clearTimeout(entry.deadline ?? undefined) + } + unconfirmedRef.current = [] + } + }, []) + + return { holdUnconfirmedSend } +} diff --git a/mobile/src/session/use-mobile-structured-agent-mutation.ts b/mobile/src/session/use-mobile-structured-agent-mutation.ts new file mode 100644 index 000000000000..2d3c95f613e5 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-mutation.ts @@ -0,0 +1,74 @@ +// The one mutation seam the structured session hook hands its features: builds +// the envelope, retains replayable operation ids per intent, and folds refusals +// into the session's error channel. + +import { useCallback } from 'react' +import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' +import type { RpcClient } from '../transport/rpc-client' +import { + requestStructuredAgentSessionMutation, + retainStructuredSessionOperationId, + type StructuredAgentSessionMutationResult +} from './mobile-structured-agent-session-rpc' + +export type MobileStructuredAgentMutate = ( + method: string, + fingerprintMethod: string, + fields: Record +) => Promise> + +export function useMobileStructuredAgentMutate(args: { + client: RpcClient | null + sessionId: string | null + sessionKey: string + enabled: boolean + stateRef: { readonly current: StructuredAgentSessionState } + operationIds: Map + onSendError: (message: string) => void +}): MobileStructuredAgentMutate { + const { client, enabled, onSendError, operationIds, sessionId, sessionKey, stateRef } = args + return useCallback( + async ( + method: string, + fingerprintMethod: string, + fields: Record + ): Promise> => { + const current = stateRef.current + if (!client || !sessionId || !enabled || current.fence === null) { + return { status: 'rejected' } + } + const targetFence = current.fence + const key = `${sessionKey}:${fingerprintMethod}:${JSON.stringify(fields)}` + const clientOperationId = retainStructuredSessionOperationId( + operationIds, + key, + operationIds.get(key) + ) + const result = await requestStructuredAgentSessionMutation({ + client, + method, + fingerprintMethod, + sessionId, + expectedRuntimeFence: targetFence, + fields, + clientOperationId + }) + if (result.status === 'accepted') { + operationIds.delete(key) + return { + status: 'accepted', + value: result.value, + sameFence: stateRef.current.fence === targetFence + } + } + if (result.status === 'unknown') { + operationIds.delete(key) + return result + } + operationIds.delete(key) + onSendError(result.message) + return { status: 'rejected' } + }, + [client, enabled, onSendError, operationIds, sessionId, sessionKey, stateRef] + ) +} diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx index 2e18bce750c3..4cf67076f34e 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx @@ -121,7 +121,11 @@ function Harness({ enabled: true, connected: true, agent: 'codex', - hostSupport: { promptCancel: promptCancelSupported, questionAnswers: questionAnswersSupported }, + hostSupport: { + promptCancel: promptCancelSupported, + questionAnswers: questionAnswersSupported, + queuedMessages: false + }, onSendError: vi.fn() }) return null diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts new file mode 100644 index 000000000000..e55a26e40c06 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts @@ -0,0 +1,129 @@ +// The published shapes the queued-message hook tests feed the mobile session: host answers, +// drafts, submissions and stream frames. + +import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' +import type { + AgentSessionQueuedMessage, + AgentSessionQueuePause, + AgentSessionSubscribeEvent +} from '../../../src/shared/agent-session-wire' +import type { RpcResponse } from '../transport/types' +import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' + +export const SESSION_ID = 'session-1' + +export const CAPABLE: StructuredAgentSessionHostSupport = { + promptCancel: false, + questionAnswers: false, + queuedMessages: true +} +export const LEGACY: StructuredAgentSessionHostSupport = { ...CAPABLE, queuedMessages: false } + +export function ok(result: unknown): RpcResponse { + return { id: 'request-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +/** The fields one recorded request carried, read without asserting their shape. */ +export function fieldsOf(value: unknown): Record { + return typeof value === 'object' && value !== null + ? Object.fromEntries(Object.entries(value)) + : {} +} + +export function mutationOk(value: unknown) { + return ok({ + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value + }) +} + +/** A settled submission; `queuedMessageId` marks it as that draft's hand-off. */ +export function acceptedSubmission( + clientMessageId: string, + queuedMessageId?: string +): AgentJournalSubmission { + return { + clientMessageId, + ...(queuedMessageId ? { queuedMessageId } : {}), + fence: 3, + payloadFingerprint: 'fp', + dispatchState: 'accepted', + providerItemId: `item-${clientMessageId}`, + reason: null, + submittedAt: 10, + resolvedAt: 11 + } +} + +export function queuedDraft( + overrides: Partial & { messageId: string } +): AgentSessionQueuedMessage { + return { + position: 1, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: `text of ${overrides.messageId}` }] + }, + state: 'waiting', + ...overrides + } +} + +export function snapshotEvent(input?: { + queuedMessages?: AgentSessionQueuedMessage[] | null + runningTurn?: boolean + submissions?: AgentJournalSubmission[] +}): AgentSessionSubscribeEvent { + return { + type: 'snapshot', + sessionId: SESSION_ID, + fence: 3, + page: { + sessionId: SESSION_ID, + epoch: 'epoch-1', + fence: 3, + direction: 'tail', + items: input?.runningTurn + ? [ + { + itemId: 'turn-item-1', + revision: 1, + body: { kind: 'turn', turnId: 'turn-1', state: 'running' }, + sequence: 1, + observedAt: 1 + } + ] + : [], + removedItemIds: [], + submissions: input?.submissions ?? [], + window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-1', sequence: 0 } }, + liveCursor: { epoch: 'epoch-1', sequence: 0 }, + hasOlder: false, + hasNewer: false + }, + ...(input?.queuedMessages !== undefined ? { queuedMessages: input.queuedMessages } : {}) + } +} + +export function batchEvent( + queuedMessages?: AgentSessionQueuedMessage[] | null, + submissions: AgentJournalSubmission[] = [], + queuePause?: AgentSessionQueuePause | null +): AgentSessionSubscribeEvent { + return { + type: 'batch', + sessionId: SESSION_ID, + batch: { + cursor: { epoch: 'epoch-1', sequence: 2 }, + items: [], + removedItemIds: [], + submissions + }, + ...(queuedMessages !== undefined ? { queuedMessages } : {}), + ...(queuePause !== undefined ? { queuePause } : {}) + } +} diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx new file mode 100644 index 000000000000..0b27efac61d3 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx @@ -0,0 +1,808 @@ +// The mid-turn queue on mobile: `delivery` rides only capability-gated sends, +// published drafts render as cards (never optimistic bubbles), and card actions +// map to the queued-message RPCs. Stop never touches the queue — its cards +// stay on the host under a queue-level pause, lifted by Resume, and no text +// travels back over the wire. +// Edit copies the card's shown text into the composer before its delete +// leaves, so no RPC outcome can lose it. An incapable host gets exactly +// today's requests. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { structuredAgentSessionPayloadFingerprint } from '../../../src/shared/structured-agent-session-mutation' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal' +import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' +import { + CAPABLE, + LEGACY, + SESSION_ID, + acceptedSubmission, + batchEvent, + fieldsOf, + mutationOk, + ok, + queuedDraft, + snapshotEvent +} from './use-mobile-structured-agent-session-queued.test-fixture' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) + +describe('mobile structured queued messages', () => { + let renderer: ReactTestRenderer | null = null + let hook: ReturnType | null = null + let listener: ((value: unknown) => void) | null = null + let stored: Map + const onSendError = vi.fn() + const appendText = vi.fn((_text: string) => true) + const sendRequest = vi.fn() + const subscribe = vi.fn((_method, _params, onData) => { + listener = onData + return vi.fn() + }) + const client: RpcClient = { + sendRequest, + subscribe, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + + function Harness({ + hostSupport, + sessionId = SESSION_ID + }: { + hostSupport: StructuredAgentSessionHostSupport + sessionId?: string + }): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId, + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected: true, + agent: 'claude', + hostSupport, + appendComposerText: appendText, + onSendError + }) + return null + } + + async function mountSession( + hostSupport: StructuredAgentSessionHostSupport, + event: AgentSessionSubscribeEvent = snapshotEvent(), + sessionId?: string + ): Promise { + act(() => { + renderer = create( + createElement(Harness, { hostSupport, ...(sessionId ? { sessionId } : {}) }) + ) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(event)) + } + + function unmountSession(): void { + act(() => renderer?.unmount()) + renderer = null + hook = null + listener = null + } + + function calls(method: string) { + return sendRequest.mock.calls.filter(([calledMethod]) => calledMethod === method) + } + + /** Params of the `index`th `method` request, and its envelope. */ + function requestOf(method: string, index = 0) { + const params = fieldsOf(calls(method)[index]?.[1]) + return { params, envelope: fieldsOf(params.envelope) } + } + + /** Global invocation order of the first `method` request, for cross-spy ordering. */ + function callOrderOf(method: string): number | undefined { + return sendRequest.mock.invocationCallOrder.find( + (_, index) => sendRequest.mock.calls[index]?.[0] === method + ) + } + + beforeEach(() => { + vi.clearAllMocks() + resetMobileStructuredSendOperationJournalForTests() + stored = new Map() + asyncStorage.getItem.mockImplementation(async (key: string) => stored.get(key) ?? null) + asyncStorage.setItem.mockImplementation(async (key: string, value: string) => { + stored.set(key, value) + }) + asyncStorage.removeItem.mockImplementation(async (key: string) => { + stored.delete(key) + }) + sendRequest.mockImplementation(async (method) => + method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + ) + }) + + afterEach(() => { + vi.useRealTimers() + unmountSession() + }) + + describe('capability-gated delivery', () => { + it('sends delivery: queue-if-active — fingerprint included — only on a capable host', async () => { + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.send') { + return mutationOk({ + clientMessageId: 'client-1', + queued: { messageId: 'client-1', position: 1, state: 'waiting' } + }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(CAPABLE) + await act(async () => { + expect(await hook!.sendWithOutcome('queue me')).toBe('queued') + }) + const { params, envelope } = requestOf('agentSession.send') + expect(params.delivery).toBe('queue-if-active') + expect(envelope.payloadFingerprint).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION_ID, + fields: { body: params.body, delivery: 'queue-if-active' } + }) + ) + // Spent at `queued`: the durable send-operation entry is released. + await vi.waitFor(() => + expect(stored.has('orca:mobileStructuredSendOperations:v1')).toBe(false) + ) + }) + + it('keeps today’s request exactly against an incapable host', async () => { + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.send') { + return mutationOk({ + clientMessageId: 'client-1', + submission: { + clientMessageId: 'client-1', + fence: 3, + payloadFingerprint: 'fp', + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 10, + resolvedAt: 10 + } + }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(LEGACY) + await act(async () => { + expect(await hook!.sendWithOutcome('plain send')).toBe('accepted') + }) + const { params, envelope } = requestOf('agentSession.send') + expect('delivery' in params).toBe(false) + expect(envelope.payloadFingerprint).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION_ID, + fields: { body: params.body } + }) + ) + }) + + it('replays an ack-lost delivery send under one id and the delivery it was sent with', async () => { + let attempts = 0 + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.send') { + attempts += 1 + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(CAPABLE) + await act(async () => { + expect(await hook!.sendWithOutcome('retry me')).toBe('unknown') + }) + unmountSession() + // The capability probe has not answered after the reload, but the recorded + // operation must replay bit-for-bit — content-derived key, same id, same + // delivery field — or the host would refuse it as a fingerprint conflict. + await mountSession(LEGACY) + await act(async () => { + expect(await hook!.sendWithOutcome('retry me')).toBe('unknown') + }) + expect(attempts).toBe(2) + const first = requestOf('agentSession.send', 0) + const second = requestOf('agentSession.send', 1) + expect(second.params.delivery).toBe('queue-if-active') + expect(second.envelope.clientOperationId).toBe(first.envelope.clientOperationId) + // Nothing new is persisted: an older build still reads the send journal. + const journal = stored.get('orca:mobileStructuredSendOperations:v1') ?? '' + expect(journal).not.toContain('delivery') + }) + + it('retires an ack-lost delivery send an older host refuses, so the next send goes out plain', async () => { + const journalKey = 'orca:mobileStructuredSendOperations:v1' + let attempts = 0 + sendRequest.mockImplementation(async (method, params) => { + if (method === 'agentSession.send') { + attempts += 1 + if (attempts <= 2) { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + // The downgraded host's strict schema turns `delivery` away before running anything. + if ('delivery' in fieldsOf(params)) { + return { + id: 'request-1', + ok: false, + error: { code: 'invalid_argument', message: 'Unrecognized key: "delivery"' } + } + } + return mutationOk({ + clientMessageId: 'client-plain', + submission: { + clientMessageId: 'client-plain', + fence: 3, + payloadFingerprint: 'fp', + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 10, + resolvedAt: 10 + } + }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(CAPABLE) + await act(async () => { + expect(await hook!.sendWithOutcome('downgraded')).toBe('unknown') + }) + unmountSession() + await mountSession(LEGACY) + // A lost answer is still doubt: the replay keeps the id and its delivery. + await act(async () => { + expect(await hook!.sendWithOutcome('downgraded')).toBe('unknown') + }) + const first = requestOf('agentSession.send', 0) + expect(requestOf('agentSession.send', 1).envelope.clientOperationId).toBe( + first.envelope.clientOperationId + ) + expect(stored.get(journalKey)).toContain(String(first.envelope.clientOperationId)) + // The host answering that it cannot take the request retires the entry, once. + await act(async () => { + expect(await hook!.sendWithOutcome('downgraded')).toBe('rejected') + }) + const refused = requestOf('agentSession.send', 2) + expect(refused.params.delivery).toBe('queue-if-active') + expect(refused.envelope.clientOperationId).toBe(first.envelope.clientOperationId) + expect(onSendError).toHaveBeenCalledTimes(1) + await act(async () => { + expect(await hook!.sendWithOutcome('downgraded')).toBe('accepted') + }) + const plain = requestOf('agentSession.send', 3) + expect('delivery' in plain.params).toBe(false) + expect(plain.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId) + expect(attempts).toBe(4) + }) + + it('keeps an ack-lost send when the host refuses its replay as unauthorized', async () => { + let attempts = 0 + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.send') { + attempts += 1 + if (attempts === 1) { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + // An auth refusal says nothing about whether the first attempt was delivered. + return { + id: 'request-1', + ok: false, + error: { code: 'unauthorized', message: 'Pairing revoked' } + } + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(CAPABLE) + await act(async () => { + expect(await hook!.sendWithOutcome('in doubt')).toBe('unknown') + }) + await act(async () => { + expect(await hook!.sendWithOutcome('in doubt')).toBe('rejected') + }) + await act(async () => { + await hook!.sendWithOutcome('in doubt') + }) + const first = requestOf('agentSession.send', 0) + expect(requestOf('agentSession.send', 2).envelope.clientOperationId).toBe( + first.envelope.clientOperationId + ) + }) + }) + + it('an ack-lost send is spent once the host publishes it as a draft, even one later withdrawn', async () => { + const journalKey = 'orca:mobileStructuredSendOperations:v1' + let attempts = 0 + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.send') { + attempts += 1 + if (attempts === 1) { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + return mutationOk({ + clientMessageId: `client-${attempts}`, + queued: { messageId: `client-${attempts}`, position: 1, state: 'waiting' } + }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(CAPABLE, snapshotEvent({ runningTurn: true })) + await act(async () => { + expect(await hook!.sendWithOutcome('held')).toBe('unknown') + }) + const operationId = String(requestOf('agentSession.send').envelope.clientOperationId) + expect(stored.get(journalKey)).toContain(operationId) + // Someone else's draft proves nothing about this send. + act(() => listener?.(batchEvent([queuedDraft({ messageId: 'other-device' })]))) + await act(async () => {}) + expect(stored.get(journalKey)).toContain(operationId) + // The host names the draft by this send's operation id: that is its receipt. + act(() => listener?.(batchEvent([queuedDraft({ messageId: operationId })]))) + await vi.waitFor(() => expect(stored.has(journalKey)).toBe(false)) + // Deleted elsewhere: no submission will ever settle it, and nothing has to. + act(() => listener?.(batchEvent(null))) + await act(async () => { + expect(await hook!.sendWithOutcome('held')).toBe('queued') + }) + expect(requestOf('agentSession.send', 1).envelope.clientOperationId).not.toBe(operationId) + }) + + it('an identical send whose retained id replays as withdrawn goes out fresh', async () => { + let attempts = 0 + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.send') { + attempts += 1 + if (attempts === 1) { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + const state = attempts === 2 ? 'withdrawn' : 'waiting' + // A pruned deleted card's receipt names no place in the queue: position 0. + return mutationOk({ + clientMessageId: `client-${attempts}`, + queued: { + messageId: `client-${attempts}`, + position: state === 'withdrawn' ? 0 : 1, + state + } + }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(CAPABLE) + await act(async () => { + expect(await hook!.sendWithOutcome('again')).toBe('unknown') + }) + // A Delete spent the ack-lost draft before it reached the agent; typing the + // same words again is a new message, not a replay to swallow. + await act(async () => { + expect(await hook!.sendWithOutcome('again')).toBe('queued') + }) + expect(attempts).toBe(3) + const ids = [0, 1, 2].map( + (index) => requestOf('agentSession.send', index).envelope.clientOperationId + ) + expect(ids[1]).toBe(ids[0]) + expect(ids[2]).not.toBe(ids[0]) + }) + + it('a withdrawn replay whose record storage will not clear still goes out fresh, and says so', async () => { + let attempts = 0 + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.send') { + attempts += 1 + if (attempts === 1) { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + const state = attempts === 2 ? 'withdrawn' : 'waiting' + return mutationOk({ + clientMessageId: `client-${attempts}`, + queued: { messageId: `client-${attempts}`, position: 1, state } + }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(CAPABLE) + await act(async () => { + expect(await hook!.sendWithOutcome('again')).toBe('unknown') + }) + // The retained record cannot be cleared; that must not keep this text from being sent. + asyncStorage.setItem.mockRejectedValue(new Error('disk full')) + asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) + await act(async () => { + expect(await hook!.sendWithOutcome('again')).toBe('queued') + }) + expect(attempts).toBe(3) + const ids = [0, 1, 2].map( + (index) => requestOf('agentSession.send', index).envelope.clientOperationId + ) + expect(ids[1]).toBe(ids[0]) + expect(ids[2]).not.toBe(ids[0]) + expect(onSendError).toHaveBeenCalledWith( + "Sent, but this phone couldn't update its record of sent messages." + ) + }) + + describe('a lost answer, then Stop, then the drain under a fresh id', () => { + const journalKey = 'orca:mobileStructuredSendOperations:v1' + let attempts = 0 + let lostId = '' + + beforeEach(() => { + attempts = 0 + lostId = '' + sendRequest.mockImplementation(async (method, params) => { + if (method === 'agentSession.send') { + attempts += 1 + const id = String(fieldsOf(fieldsOf(params).envelope).clientOperationId) + if (attempts === 1) { + lostId = id + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + if (id === lostId) { + // The replay answers with the hand-off, which names the replayed id as its draft. + return mutationOk({ + clientMessageId: id, + submission: acceptedSubmission('fresh-hand-off', id) + }) + } + return mutationOk({ + clientMessageId: id, + queued: { messageId: id, position: 1, state: 'waiting' } + }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + }) + + it('spends the record once the stream carries the hand-off naming it; a direct send does not', async () => { + await mountSession(CAPABLE) + await act(async () => { + expect(await hook!.sendWithOutcome('again')).toBe('unknown') + }) + expect(stored.get(journalKey)).toContain(lostId) + // A direct send's submission names no draft, so it settles nothing here. + act(() => listener?.(batchEvent(undefined, [acceptedSubmission('someone-else')]))) + await act(async () => {}) + expect(stored.get(journalKey)).toContain(lostId) + // The drain's hand-off went out under a fresh id and names the lost send's draft. + act(() => listener?.(batchEvent(undefined, [acceptedSubmission('fresh-hand-off', lostId)]))) + await vi.waitFor(() => expect(stored.has(journalKey)).toBe(false)) + await act(async () => { + expect(await hook!.sendWithOutcome('again')).toBe('queued') + }) + expect(attempts).toBe(2) + expect(requestOf('agentSession.send', 1).envelope.clientOperationId).not.toBe(lostId) + }) + + it('spends the record from a replay answered by the hand-off, which no page carries', async () => { + await mountSession(CAPABLE) + await act(async () => { + expect(await hook!.sendWithOutcome('again')).toBe('unknown') + }) + // The host holds that message now; the phone paints no bubble for it. The stream never + // carries the hand-off, so the answer's link is what spends the record. + await act(async () => { + expect(await hook!.sendWithOutcome('again')).toBe('unknown') + }) + expect(requestOf('agentSession.send', 1).envelope.clientOperationId).toBe(lostId) + await vi.waitFor(() => expect(stored.has(journalKey)).toBe(false)) + await act(async () => { + expect(await hook!.sendWithOutcome('again')).toBe('queued') + }) + expect(requestOf('agentSession.send', 2).envelope.clientOperationId).not.toBe(lostId) + }) + }) + + describe('cards from the published list', () => { + it('renders published drafts as cards and follows later frames', async () => { + await mountSession( + CAPABLE, + snapshotEvent({ queuedMessages: [queuedDraft({ messageId: 'draft-1' })] }) + ) + expect(hook!.queued.cards).toEqual([ + { + messageId: 'draft-1', + text: 'text of draft-1', + state: 'waiting', + paused: false, + needsAttention: false, + caption: null + } + ]) + // A frame without the field leaves the list alone; null empties it. + act(() => listener?.(batchEvent())) + expect(hook!.queued.cards).toHaveLength(1) + act(() => listener?.(batchEvent(null))) + expect(hook!.queued.cards).toEqual([]) + }) + + it('hides a waiting card once a submission names it as its hand-off, as the desktop does', async () => { + await mountSession( + CAPABLE, + snapshotEvent({ + queuedMessages: [ + queuedDraft({ messageId: 'drained' }), + queuedDraft({ messageId: 'still-waiting' }) + ], + submissions: [acceptedSubmission('fresh-hand-off-id', 'drained')] + }) + ) + expect(hook!.queued.cards.map((card) => card.messageId)).toEqual(['still-waiting']) + }) + + it('never hides a card by a direct send that merely shares its id', async () => { + await mountSession( + CAPABLE, + snapshotEvent({ + queuedMessages: [queuedDraft({ messageId: 'same-id' })], + submissions: [acceptedSubmission('same-id')] + }) + ) + expect(hook!.queued.cards.map((card) => card.messageId)).toEqual(['same-id']) + }) + + it('shows no cards from an incapable host even if a list arrives', async () => { + await mountSession( + LEGACY, + snapshotEvent({ queuedMessages: [queuedDraft({ messageId: 'draft-1' })] }) + ) + expect(hook!.queued.cards).toEqual([]) + }) + }) + + describe('card actions', () => { + it('Send-now consumes through agentSession.queuedMessageSend', async () => { + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.queuedMessageSend') { + return mutationOk({ + clientMessageId: 'draft-1', + // Every hand-off goes out under a fresh id and names its draft. + submission: { + clientMessageId: 'hand-off-1', + queuedMessageId: 'draft-1', + fence: 3, + payloadFingerprint: 'fp', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 10, + resolvedAt: null + } + }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(CAPABLE) + await act(async () => { + expect(await hook!.queued.send('draft-1')).toBe(true) + }) + expect(requestOf('agentSession.queuedMessageSend').params.messageId).toBe('draft-1') + }) + + it('Delete reads the union result: a dispatched draft was already sent', async () => { + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.queuedMessageDelete') { + return mutationOk({ deleted: false, messageId: 'draft-1', disposition: 'dispatched' }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(CAPABLE) + await act(async () => { + expect(await hook!.queued.delete('draft-1')).toBe(false) + }) + expect(onSendError).toHaveBeenCalledWith('This message was already sent.') + }) + + it('Edit copies the card’s shown text into the composer before its delete leaves', async () => { + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.queuedMessageDelete') { + return mutationOk({ deleted: true, messageId: 'draft-1' }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession( + CAPABLE, + snapshotEvent({ queuedMessages: [queuedDraft({ messageId: 'draft-1' })] }) + ) + await act(async () => { + expect(await hook!.queued.edit('draft-1')).toBe(true) + }) + // The copy comes from the card the user is looking at, not from any answer. + expect(appendText.mock.calls).toEqual([['text of draft-1']]) + // Copy-first: the text was in the composer before the delete RPC left, so + // no Delete outcome can lose it. + expect(appendText.mock.invocationCallOrder[0]).toBeLessThan( + callOrderOf('agentSession.queuedMessageDelete')! + ) + // A plain delete: only the message id goes out, and nothing durable is written. + expect(requestOf('agentSession.queuedMessageDelete').params.messageId).toBe('draft-1') + expect(asyncStorage.setItem).not.toHaveBeenCalled() + }) + + it('Edit keeps the copied text when the delete fails; the card stays beside it', async () => { + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.queuedMessageDelete') { + return { id: 'request-1', ok: false, error: { code: 'runtime_error', message: 'boom' } } + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession( + CAPABLE, + snapshotEvent({ queuedMessages: [queuedDraft({ messageId: 'draft-1' })] }) + ) + await act(async () => { + expect(await hook!.queued.edit('draft-1')).toBe(false) + }) + // The user sees both copies — composer text and the surviving card — and + // can press Delete again; nothing is lost and nothing is restored twice. + expect(appendText.mock.calls).toEqual([['text of draft-1']]) + expect(hook!.queued.cards.map((card) => card.messageId)).toEqual(['draft-1']) + }) + + it('Edit racing the drain still copies; the user hears it already went out', async () => { + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.queuedMessageDelete') { + return mutationOk({ deleted: false, messageId: 'draft-1', disposition: 'dispatched' }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession( + CAPABLE, + snapshotEvent({ queuedMessages: [queuedDraft({ messageId: 'draft-1' })] }) + ) + await act(async () => { + expect(await hook!.queued.edit('draft-1')).toBe(false) + }) + expect(appendText.mock.calls).toEqual([['text of draft-1']]) + // The copy is still in the composer: say so, or it reads as unsent and goes out twice. + expect(onSendError).toHaveBeenCalledWith('Already sent — your text is still in the composer.') + }) + + it('Edit that copied nothing deletes nothing and does not report a copy', async () => { + sendRequest.mockImplementation(async (method) => + method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + ) + await mountSession( + CAPABLE, + snapshotEvent({ queuedMessages: [queuedDraft({ messageId: 'draft-1' })] }) + ) + appendText.mockReturnValueOnce(false) + const onCopied = vi.fn() + await act(async () => { + expect(await hook!.queued.edit('draft-1', onCopied)).toBe(false) + }) + expect(appendText.mock.calls).toEqual([['text of draft-1']]) + expect(onCopied).not.toHaveBeenCalled() + expect(callOrderOf('agentSession.queuedMessageDelete')).toBeUndefined() + expect(hook!.queued.cards.map((card) => card.messageId)).toEqual(['draft-1']) + }) + }) + + describe('Stop leaves the queue alone', () => { + it('a capable Stop is a plain cancel; the cards stay and the queue reads as paused', async () => { + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.cancel') { + return mutationOk({ cancelled: true, turnId: 'turn-1' }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession( + CAPABLE, + snapshotEvent({ + runningTurn: true, + queuedMessages: [queuedDraft({ messageId: 'draft-1' })] + }) + ) + await act(async () => { + expect(await hook!.cancelPrompt()).toBe(true) + }) + // Exactly today's cancel: no withdrawal ask, no text owed back, nothing durable. + const { params } = requestOf('agentSession.cancel') + expect(Object.keys(params).sort()).toEqual(['envelope', 'turnId']) + expect(params.turnId).toBe('turn-1') + expect(appendText).not.toHaveBeenCalled() + expect(asyncStorage.setItem).not.toHaveBeenCalled() + expect(hook!.queued.cards.map((card) => card.messageId)).toEqual(['draft-1']) + // The host's pause is the queue's, published beside the list; the card itself is not held. + act(() => + listener?.(batchEvent([queuedDraft({ messageId: 'draft-1' })], [], { reason: 'stopped' })) + ) + expect(hook!.queued.pause).toEqual({ reason: 'stopped' }) + expect(hook!.queued.cards[0]).toMatchObject({ + messageId: 'draft-1', + paused: false, + caption: null + }) + // A frame without the list leaves the pause alone; one that publishes the list states it. + act(() => listener?.(batchEvent())) + expect(hook!.queued.pause).toEqual({ reason: 'stopped' }) + act(() => listener?.(batchEvent([queuedDraft({ messageId: 'draft-1' })], [], null))) + expect(hook!.queued.pause).toBeNull() + }) + + it('shows no pause while Resume would send nothing: only returned, blocked or failed cards', async () => { + const returned = queuedDraft({ messageId: 'r', state: 'returned', returnedReason: 'refused' }) + const behind = queuedDraft({ messageId: 'b', position: 2 }) + const failed = queuedDraft({ messageId: 'f', paused: true, pausedReason: 'send_failed' }) + await mountSession(CAPABLE, snapshotEvent({ queuedMessages: [] })) + for (const list of [[returned], [returned, behind], [failed]]) { + act(() => listener?.(batchEvent(list, [], { reason: 'stopped' }))) + expect(hook!.queued.pause).toBeNull() + } + act(() => listener?.(batchEvent([failed, behind], [], { reason: 'stopped' }))) + expect(hook!.queued.pause).toEqual({ reason: 'stopped' }) + }) + + it('Resume lifts the pause through its RPC, and a refusal reaches the error banner', async () => { + let refuse = false + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.queuedMessagesResume') { + return refuse + ? ok({ + ok: false, + refusal: { code: 'agent_session_operation_invalid', message: 'Could not resume.' } + }) + : mutationOk({ resumed: true }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession( + CAPABLE, + snapshotEvent({ queuedMessages: [queuedDraft({ messageId: 'draft-1' })] }) + ) + await act(async () => { + expect(await hook!.queued.resume()).toBe(true) + }) + expect(Object.keys(requestOf('agentSession.queuedMessagesResume').params)).toEqual([ + 'envelope' + ]) + refuse = true + await act(async () => { + expect(await hook!.queued.resume()).toBe(false) + }) + expect(onSendError).toHaveBeenCalledWith(expect.any(String)) + }) + + it('an incapable Stop is exactly today’s cancel', async () => { + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.cancel') { + return mutationOk({ cancelled: true, turnId: 'turn-1' }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(LEGACY, snapshotEvent({ runningTurn: true })) + await act(async () => { + expect(await hook!.cancelPrompt()).toBe(true) + }) + const { params } = requestOf('agentSession.cancel') + expect(Object.keys(params).sort()).toEqual(['envelope', 'turnId']) + expect(asyncStorage.setItem).not.toHaveBeenCalled() + }) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.test.tsx index bcc24c5ed322..04b8e87304a2 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session.test.tsx @@ -10,6 +10,7 @@ import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-sessi import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { formatQuestionFreeTextAnswer } from './mobile-native-chat-question' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' @@ -365,7 +366,7 @@ describe('useMobileStructuredAgentSession', () => { await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) act(() => listener?.(snapshotEvent())) - let outcome: 'accepted' | 'unknown' | 'rejected' = 'rejected' + let outcome: MobileNativeChatSendOutcome = 'rejected' await act(async () => { outcome = await hook!.sendWithOutcome('hello') }) @@ -496,7 +497,7 @@ describe('useMobileStructuredAgentSession', () => { await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) act(() => listener?.(snapshotEvent(3))) - let outcome: 'accepted' | 'unknown' | 'rejected' = 'rejected' + let outcome: MobileNativeChatSendOutcome = 'rejected' await act(async () => { outcome = await hook.sendWithOutcome('look at this', undefined, undefined, [ { path: '/tmp/a.png', previewUri: 'file:///a.jpg' } @@ -534,7 +535,7 @@ describe('useMobileStructuredAgentSession', () => { act(() => listener?.(snapshotEvent(3))) sendRequest.mockClear() - let outcome: 'accepted' | 'unknown' | 'rejected' = 'accepted' + let outcome: MobileNativeChatSendOutcome = 'accepted' await act(async () => { outcome = await hook!.sendWithOutcome('look at this', ['file:///a.jpg']) }) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 2fa5c2b2a96b..8b7e15514019 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -1,9 +1,4 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' -import { dispatchMobileStructuredCommand } from './mobile-structured-composer-command' -import { - structuredAgentSessionSendBody, - type StructuredAgentSessionAttachment -} from '../../../src/shared/structured-agent-session-outbox' import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' @@ -19,12 +14,6 @@ import { projectStructuredPermission, projectStructuredQuestion } from './mobile-structured-agent-prompts' -import { - requestStructuredAgentSessionMutation, - retainStructuredSessionOperationId as retainStructuredOpId, - timeoutForDeadline, - type StructuredAgentSessionMutationResult -} from './mobile-structured-agent-session-rpc' import type { RpcClient } from '../transport/rpc-client' import type { MobileChatPermission } from './mobile-native-chat-permission' import type { MobileChatQuestion } from './mobile-native-chat-question' @@ -35,17 +24,20 @@ import { useMobileStructuredPromptResponses } from './use-mobile-structured-prom import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' import { useMobileStructuredAgentTurnTiming } from './use-mobile-structured-agent-turn-timing' -import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' import { useMobileStructuredSendOperationReconciliation } from './use-mobile-structured-send-operation-reconciliation' import { pendingStructuredPromptIdentity, requestMobileStructuredAgentSessionCancel } from './mobile-structured-agent-session-cancel' - -type StructuredMobileAttachment = StructuredAgentSessionAttachment & { - id?: string - contentFingerprint?: string -} +import { useMobileStructuredAgentMutate } from './use-mobile-structured-agent-mutation' +import { + useMobileStructuredSendWithOutcome, + type StructuredMobileSendAttachment +} from './use-mobile-structured-send-with-outcome' +import { + useMobileStructuredQueuedMessageControls, + type MobileStructuredQueuedMessageControls +} from './use-mobile-structured-queued-message-controls' type StructuredMobileSession = ReturnType & ReturnType & { @@ -58,7 +50,7 @@ type StructuredMobileSession = ReturnType Promise cancel: () => void permission: MobileChatPermission | null @@ -66,6 +58,8 @@ type StructuredMobileSession = ReturnType Promise respondQuestion: (answer: string) => Promise cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => Promise + /** The queued-draft cards and their actions; empty and inert off capable hosts. */ + queued: MobileStructuredQueuedMessageControls } export function useMobileStructuredAgentSession(args: { @@ -81,72 +75,46 @@ export function useMobileStructuredAgentSession(args: { /** Capability facts from the shared runtime status probe; null follows the legacy wire. */ hostSupport: StructuredAgentSessionHostSupport | null agent: string | null + /** The active pane's live composer; Edit copies a card's text through it. */ + appendComposerText?: (text: string) => boolean onSendError: (message: string) => void + /** Called on any accepted queued-card action; retires the route's failure banner. */ + onActionResolved?: () => void }): StructuredMobileSession { const { agent, + appendComposerText, callerIdentity = '', client, connected, sessionId, sourceIdentity = '', enabled, + onActionResolved, onSendError, hostSupport } = args + // Old host ⇒ exactly today's behavior: no delivery field, no cards, plain Stop. + const queueCapable = hostSupport?.queuedMessages === true const promptCancelSupported = hostSupport?.promptCancel ?? null const sessionKey = encodeNativeChatTranscriptIdentity([sourceIdentity, agent, sessionId]) const operationIdsRef = useRef(new Map()) const commandPendingRef = useRef(false) useEffect(() => () => operationIdsRef.current.clear(), []) const stateArgs = { client, sessionId, sessionKey, enabled, connected } - const { state, stateRef, loadingOlder, loadEarlier } = useMobileStructuredAgentState(stateArgs) - useMobileStructuredSendOperationReconciliation(state.submissions) + const { state, stateRef, queuedMessages, queuePause, loadingOlder, loadEarlier } = + useMobileStructuredAgentState(stateArgs) + useMobileStructuredSendOperationReconciliation(state.submissions, queuedMessages) - const mutate = useCallback( - async ( - method: string, - fingerprintMethod: string, - fields: Record - ): Promise> => { - const current = stateRef.current - if (!client || !sessionId || !enabled || current.fence === null) { - return { status: 'rejected' } - } - const targetFence = current.fence - const key = `${sessionKey}:${fingerprintMethod}:${JSON.stringify(fields)}` - const clientOperationId = retainStructuredOpId( - operationIdsRef.current, - key, - operationIdsRef.current.get(key) - ) - const result = await requestStructuredAgentSessionMutation({ - client, - method, - fingerprintMethod, - sessionId, - expectedRuntimeFence: targetFence, - fields, - clientOperationId - }) - if (result.status === 'accepted') { - operationIdsRef.current.delete(key) - return { - status: 'accepted', - value: result.value, - sameFence: stateRef.current.fence === targetFence - } - } - if (result.status === 'unknown') { - operationIdsRef.current.delete(key) - return result - } - operationIdsRef.current.delete(key) - onSendError(result.message) - return { status: 'rejected' } - }, - [client, enabled, onSendError, sessionId, sessionKey] - ) + const mutate = useMobileStructuredAgentMutate({ + client, + sessionId, + sessionKey, + enabled, + stateRef, + operationIds: operationIdsRef.current, + onSendError + }) const options = useMobileStructuredAgentOptions({ agent, @@ -158,86 +126,31 @@ export function useMobileStructuredAgentSession(args: { }) const { conversationCommands, invokeStructuredOption, optionSnapshot, setStructuredOption } = options - - const sendWithOutcome = useCallback( - async ( - text: string, - images?: string[], - deadline?: number, - attachments?: readonly StructuredMobileAttachment[] - ): Promise => { - const currentFence = stateRef.current.fence - if (!client || !sessionId || !enabled || currentFence === null) { - onSendError('Message not sent (disconnected)') - return 'rejected' - } - const timeoutMs = timeoutForDeadline(deadline) - if (timeoutMs === null) { - onSendError('Message not sent') - return 'rejected' - } - if (attachments === undefined && images !== undefined && images.length > 0) { - onSendError('Message not sent') - return 'rejected' - } - const sendAttachments = attachments ?? [] - const commandOutcome = await dispatchMobileStructuredCommand({ - text, - hasAttachments: Boolean(sendAttachments.length || images?.length), - client, - sessionId, - fence: currentFence, - sessionKey, - pending: commandPendingRef, - operationIds: operationIdsRef.current, - controller: { - agent: agent === 'claude' ? 'claude' : 'codex', - snapshot: optionSnapshot, - setOption: setStructuredOption, - invokeAction: invokeStructuredOption, - conversationCommands - }, - canRun: () => - !activeStructuredAgentSessionTurnId(stateRef.current.items) && - !stateRef.current.items.some( - (item) => pendingStructuredApproval(item) || pendingStructuredQuestion(item) - ), - onError: onSendError, - timeoutMs - }) - if (commandOutcome !== null) { - return commandOutcome - } - const body = structuredAgentSessionSendBody(text, sendAttachments) - if (body.blocks.length === 0) { - return 'rejected' - } - return sendMobileStructuredAgentSessionMessage({ - client, - sessionId, - sessionKey, - callerIdentity, - expectedRuntimeFence: currentFence, - text, - attachments: sendAttachments, - deadline, - onError: onSendError - }) - }, - [ - agent, - callerIdentity, - client, - conversationCommands, - enabled, - invokeStructuredOption, - onSendError, - optionSnapshot, - sessionId, - sessionKey, - setStructuredOption - ] + // Stable across renders, or the send callback is rebuilt on every streamed frame. + const sendController = useMemo( + () => ({ + snapshot: optionSnapshot, + setOption: setStructuredOption, + invokeAction: invokeStructuredOption, + conversationCommands + }), + [conversationCommands, invokeStructuredOption, optionSnapshot, setStructuredOption] ) + + const sendWithOutcome = useMobileStructuredSendWithOutcome({ + agent, + callerIdentity, + client, + sessionId, + sessionKey, + enabled, + queueCapable, + stateRef, + commandPending: commandPendingRef, + operationIds: operationIdsRef.current, + controller: sendController, + onSendError + }) const { groupedDraft, respondPermission, respondQuestion } = useMobileStructuredPromptResponses({ stateRef, sessionKey, @@ -246,22 +159,6 @@ export function useMobileStructuredAgentSession(args: { onSendError }) - const requestCancel = useCallback( - (prompt?: { itemId: string; expectedRevision: number }): Promise => - requestMobileStructuredAgentSessionCancel({ - client, - enabled, - onSendError, - operationIds: operationIdsRef.current, - prompt, - promptCancelSupported, - sessionId, - sessionKey, - stateRef - }), - [client, enabled, onSendError, promptCancelSupported, sessionId, sessionKey, stateRef] - ) - const messages = useMemo( () => projectStructuredAgentSessionMessages(state.items, [], state.submissions), [state.items, state.submissions] @@ -281,6 +178,36 @@ export function useMobileStructuredAgentSession(args: { () => state.items.find(pendingStructuredQuestion) ?? null, [state.items] ) + const queued = useMobileStructuredQueuedMessageControls({ + queueCapable, + sessionKey, + queuedMessages, + queuePause, + submissions: state.submissions, + pendingPrompt: approvalPrompt !== null || questionPrompt !== null, + mutate, + appendComposerText, + onSendError, + ...(onActionResolved ? { onActionResolved } : {}) + }) + // Stop never touches the queue: held cards stay on the host, visible on every + // device, and resume only from the user's own next action. + const requestCancel = useCallback( + (prompt?: { itemId: string; expectedRevision: number }): Promise => + requestMobileStructuredAgentSessionCancel({ + client, + enabled, + onSendError, + operationIds: operationIdsRef.current, + prompt, + promptCancelSupported, + sessionId, + sessionKey, + stateRef + }), + [client, enabled, onSendError, promptCancelSupported, sessionId, sessionKey, stateRef] + ) + return { ...options, session: { @@ -305,6 +232,7 @@ export function useMobileStructuredAgentSession(args: { permission: projectStructuredPermission(approvalPrompt), question: projectStructuredQuestion(questionPrompt, groupedDraft), respondPermission, - respondQuestion + respondQuestion, + queued } } diff --git a/mobile/src/session/use-mobile-structured-agent-state.ts b/mobile/src/session/use-mobile-structured-agent-state.ts index 3dcdc76a8b56..3f83ec9d915c 100644 --- a/mobile/src/session/use-mobile-structured-agent-state.ts +++ b/mobile/src/session/use-mobile-structured-agent-state.ts @@ -17,6 +17,15 @@ import { agentSessionReadFailureText, callAgentSession } from './mobile-structured-agent-session-rpc' +import { + reduceMobileQueuePause, + reduceMobileQueuedMessageFeed, + type MobileQueuedMessageFeed, + type MobileQueuePause +} from './mobile-structured-queued-message-feed' + +type QueuedFeed = { messages: MobileQueuedMessageFeed; pause: MobileQueuePause } +const NO_QUEUED_FEED: QueuedFeed = { messages: null, pause: null } const MAX_RETAINED_SESSION_STATES = 32 /** Bounded so a busy stream cannot turn one Load-earlier tap into an endless read chain. */ @@ -68,6 +77,10 @@ export function useMobileStructuredAgentState(args: { }): { state: StructuredAgentSessionState stateRef: { readonly current: StructuredAgentSessionState } + /** Host-held queued drafts from the live stream; null until the host claims any. */ + queuedMessages: MobileQueuedMessageFeed + /** The whole queue's pause, published with the drafts. */ + queuePause: MobileQueuePause loadingOlder: boolean loadEarlier: () => void } { @@ -77,10 +90,14 @@ export function useMobileStructuredAgentState(args: { const [sessionStates, setSessionStates] = useState>( () => new Map() ) + const [queuedBySession, setQueuedBySession] = useState>(() => new Map()) const state = enabled && sessionKey ? (sessionStates.get(sessionKey) ?? EMPTY_STRUCTURED_AGENT_SESSION) : EMPTY_STRUCTURED_AGENT_SESSION + const queued = + (enabled && sessionKey ? queuedBySession.get(sessionKey) : undefined) ?? NO_QUEUED_FEED + const queuedMessages = queued.messages const [loadingOlder, setLoadingOlder] = useState(false) const stateRef = useRef(state) const sessionKeyRef = useRef(sessionKey) @@ -117,6 +134,34 @@ export function useMobileStructuredAgentState(args: { [sessionKey] ) + const applyQueued = useCallback( + (event: AgentSessionSubscribeEvent) => { + if (!sessionKey) { + return + } + setQueuedBySession((current) => { + const previous = current.get(sessionKey) ?? NO_QUEUED_FEED + const messages = reduceMobileQueuedMessageFeed(previous.messages, event) + const pause = reduceMobileQueuePause(previous.pause, event) + if (messages === previous.messages && pause === previous.pause) { + return current + } + const updated = new Map(current) + updated.delete(sessionKey) + updated.set(sessionKey, { messages, pause }) + while (updated.size > MAX_RETAINED_SESSION_STATES) { + const oldest = updated.keys().next().value + if (oldest === undefined) { + break + } + updated.delete(oldest) + } + return updated + }) + }, + [sessionKey] + ) + useEffect(() => { streamGenerationRef.current += 1 sessionKeyRef.current = sessionKey @@ -142,6 +187,7 @@ export function useMobileStructuredAgentState(args: { } if (isSubscribeEvent(raw)) { apply({ type: 'event', event: raw }) + applyQueued(raw) } }) return () => { @@ -161,7 +207,7 @@ export function useMobileStructuredAgentState(args: { ) .catch(() => undefined) } - }, [apply, client, connected, enabled, sessionId, sessionKey]) + }, [apply, applyQueued, client, connected, enabled, sessionId, sessionKey]) const loadEarlier = useCallback(() => { const current = stateRef.current @@ -212,5 +258,5 @@ export function useMobileStructuredAgentState(args: { }) }, [apply, client, loadingOlder, sessionId, sessionKey]) - return { state, stateRef, loadingOlder, loadEarlier } + return { state, stateRef, queuedMessages, queuePause: queued.pause, loadingOlder, loadEarlier } } diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts index 3cf432381c25..aadd8de4a1a9 100644 --- a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts @@ -76,6 +76,19 @@ describe('useMobileStructuredNativeChatSendBridge', () => { expect(restoreRejectedDraft).not.toHaveBeenCalled() }) + it('never echoes a queued send as an optimistic transcript bubble', async () => { + // The host holds the draft and publishes it as a card above the composer; + // a `pending-N` bubble here would show the message twice and retire never. + sendStructured.mockResolvedValue('queued') + mount('claude') + + await expect(sendWithOutcome('queue me')).resolves.toBe('queued') + + expect(acceptSend).not.toHaveBeenCalled() + expect(holdUnconfirmedSend).not.toHaveBeenCalled() + expect(restoreRejectedDraft).not.toHaveBeenCalled() + }) + it('keeps host-command reconciliation for Codex', async () => { sendStructured.mockResolvedValue('unknown') mount('codex') diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts index 83482cd6e2e0..5abe42b39e60 100644 --- a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts @@ -76,6 +76,11 @@ export function useMobileStructuredNativeChatSendBridge(args: { } return 'accepted' } + if (outcome === 'queued') { + // The host holds the draft and publishes it as a card above the + // composer — never an optimistic transcript bubble. + return 'queued' + } if (outcome === 'unknown') { if (isHostCommand) { restoreRejectedDraft(origin, text) diff --git a/mobile/src/session/use-mobile-structured-queued-message-controls.ts b/mobile/src/session/use-mobile-structured-queued-message-controls.ts new file mode 100644 index 000000000000..4e033c6dd972 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-queued-message-controls.ts @@ -0,0 +1,165 @@ +// The queued-draft surface the structured session exposes: cards derived from +// the published list and the Send-now / Delete / Edit actions. All of it is +// gated on the host capability — an incapable host gets no cards and no new +// fields. Nothing here is durable: the host owns the queue, and the published +// list is the only truth a card action ever needs. + +import { useCallback, useMemo } from 'react' +import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' +import type { + AgentSessionQueuedMessageDeleteResult, + AgentSessionQueuedMessagesResumeResult, + AgentSessionSendResult +} from '../../../src/shared/agent-session-wire' +import { + mobileQueueHasResumableCard, + mobileQueuedMessageCards, + type MobileQueuedMessageCard +} from './mobile-structured-queued-message-cards' +import type { + MobileQueuedMessageFeed, + MobileQueuePause +} from './mobile-structured-queued-message-feed' +import type { MobileStructuredAgentMutate } from './use-mobile-structured-agent-mutation' + +/** `onCopied` runs once the card's text is in the composer, before its Delete leaves. */ +export type MobileQueuedMessageEdit = (messageId: string, onCopied?: () => void) => Promise + +export type MobileStructuredQueuedMessageControls = { + /** Host-held drafts as cards above the composer; empty off capable hosts. */ + cards: MobileQueuedMessageCard[] + /** Send-now: dispatch this draft into or ahead of the running turn. */ + send: (messageId: string) => Promise + /** Discard the draft. */ + delete: (messageId: string) => Promise + /** Copy the card's shown text into the composer, then delete the card. */ + edit: MobileQueuedMessageEdit + /** The whole queue's pause, shown above the cards; null when it sends on its own. */ + pause: MobileQueuePause + /** Lift the queue's pause, so the waiting cards drain. */ + resume: () => Promise + /** The conversation these belong to; the card list's in-flight guards never outlive it. */ + sessionKey: string +} + +export function useMobileStructuredQueuedMessageControls(args: { + queueCapable: boolean + sessionKey: string + queuedMessages: MobileQueuedMessageFeed + queuePause: MobileQueuePause + submissions: readonly AgentJournalSubmission[] + pendingPrompt: boolean + mutate: MobileStructuredAgentMutate + /** The active pane's live composer, Edit's copy target; absent = Edit refuses. False when + * nothing was copied. */ + appendComposerText: ((text: string) => boolean) | undefined + onSendError: (message: string) => void + /** Called on any accepted card action, so the route can retire a held failure banner. */ + onActionResolved?: () => void +}): MobileStructuredQueuedMessageControls { + const { + appendComposerText, + mutate, + onActionResolved, + onSendError, + pendingPrompt, + queueCapable, + queuedMessages, + queuePause, + sessionKey, + submissions + } = args + const cards = useMemo( + () => + queueCapable + ? mobileQueuedMessageCards(queuedMessages, submissions, { + pendingPrompt, + queuePaused: queuePause !== null + }) + : [], + [pendingPrompt, queueCapable, queuePause, queuedMessages, submissions] + ) + const resolved = useCallback( + (accepted: boolean): boolean => { + if (accepted) { + onActionResolved?.() + } + return accepted + }, + [onActionResolved] + ) + const send = useCallback( + async (messageId: string): Promise => + resolved( + ( + await mutate( + 'agentSession.queuedMessageSend', + 'agentSession.queuedMessageSend', + { messageId } + ) + ).status === 'accepted' + ), + [mutate, resolved] + ) + const deleteQueued = useCallback( + async (messageId: string, copied: boolean): Promise => { + const result = await mutate( + 'agentSession.queuedMessageDelete', + 'agentSession.queuedMessageDelete', + { messageId } + ) + if (result.status !== 'accepted') { + return false + } + if (!result.value.deleted && result.value.disposition === 'dispatched') { + // Delete raced the drain; the message went out and is in the transcript. After Edit's + // copy, say so, or the composer's text reads as unsent and goes out twice. + onSendError( + copied + ? 'Already sent — your text is still in the composer.' + : 'This message was already sent.' + ) + return false + } + return resolved(true) + }, + [mutate, onSendError, resolved] + ) + const deleteDraft = useCallback( + (messageId: string) => deleteQueued(messageId, false), + [deleteQueued] + ) + const edit = useCallback( + async (messageId, onCopied) => { + const card = cards.find((candidate) => candidate.messageId === messageId) + // Copy-first: the text is in the composer before any RPC can fail, so no + // Delete outcome — including a lost answer — can lose it. A failed Delete + // leaves the card beside the copy, visibly, never a silent duplicate. No + // copy (no composer yet, or an empty card) means no Delete: Edit never + // removes text it did not keep. + if (!card || !appendComposerText?.(card.text)) { + return false + } + onCopied?.() + return deleteQueued(messageId, true) + }, + [appendComposerText, cards, deleteQueued] + ) + // Through the same mutation seam as Delete: a refusal reaches the send-error banner. + const resume = useCallback( + async (): Promise => + resolved( + ( + await mutate( + 'agentSession.queuedMessagesResume', + 'agentSession.queuedMessagesResume', + {} + ) + ).status === 'accepted' + ), + [mutate, resolved] + ) + // The header shows only while Resume would send something, as on desktop. + const pause = queueCapable && mobileQueueHasResumableCard(cards) ? queuePause : null + return { cards, send, delete: deleteDraft, edit, pause, resume, sessionKey } +} diff --git a/mobile/src/session/use-mobile-structured-send-operation-reconciliation.ts b/mobile/src/session/use-mobile-structured-send-operation-reconciliation.ts index 1b18613bc7c9..65d0f3184668 100644 --- a/mobile/src/session/use-mobile-structured-send-operation-reconciliation.ts +++ b/mobile/src/session/use-mobile-structured-send-operation-reconciliation.ts @@ -1,11 +1,24 @@ import { useEffect } from 'react' import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' -import { clearMobileStructuredSettledSendOperations } from './mobile-structured-send-operation-journal' +import type { MobileQueuedMessageFeed } from './mobile-structured-queued-message-feed' +import { + clearMobileStructuredQueuedSendOperations, + clearMobileStructuredSettledSendOperations +} from './mobile-structured-send-operation-journal' export function useMobileStructuredSendOperationReconciliation( - submissions: readonly AgentJournalSubmission[] + submissions: readonly AgentJournalSubmission[], + queuedMessages: MobileQueuedMessageFeed = null ): void { useEffect(() => { void clearMobileStructuredSettledSendOperations({ submissions }).catch(() => undefined) }, [submissions]) + useEffect(() => { + if (!queuedMessages || queuedMessages.length === 0) { + return + } + void clearMobileStructuredQueuedSendOperations({ + queuedMessageIds: queuedMessages.map((draft) => draft.messageId) + }).catch(() => undefined) + }, [queuedMessages]) } diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts new file mode 100644 index 000000000000..937cc58b0eba --- /dev/null +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -0,0 +1,143 @@ +// The structured composer's one send seam: a slash command dispatches as a +// conversation command, and everything else goes out as an +// `agentSession.send` — carrying `delivery: 'queue-if-active'` only when the +// host advertises the queue. + +import { useCallback } from 'react' +import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' +import { + structuredAgentSessionSendBody, + type StructuredAgentSessionAttachment +} from '../../../src/shared/structured-agent-session-outbox' +import type { StructuredAgentSessionComposerOptions } from '../../../src/shared/structured-agent-session-composer' +import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' +import type { RpcClient } from '../transport/rpc-client' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import { dispatchMobileStructuredCommand } from './mobile-structured-composer-command' +import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' +import { timeoutForDeadline } from './mobile-structured-agent-session-rpc' +import { + pendingStructuredApproval, + pendingStructuredQuestion +} from './mobile-structured-agent-prompts' + +export type StructuredMobileSendAttachment = StructuredAgentSessionAttachment & { + id?: string + contentFingerprint?: string +} + +export function useMobileStructuredSendWithOutcome(args: { + agent: string | null + callerIdentity: string + client: RpcClient | null + sessionId: string | null + sessionKey: string + enabled: boolean + queueCapable: boolean + stateRef: { readonly current: StructuredAgentSessionState } + commandPending: { current: boolean } + operationIds: Map + controller: Pick< + StructuredAgentSessionComposerOptions, + 'snapshot' | 'setOption' | 'invokeAction' | 'conversationCommands' + > + onSendError: (message: string) => void +}): ( + text: string, + images?: string[], + deadline?: number, + attachments?: readonly StructuredMobileSendAttachment[] +) => Promise { + const { + agent, + callerIdentity, + client, + commandPending, + controller, + enabled, + onSendError, + operationIds, + queueCapable, + sessionId, + sessionKey, + stateRef + } = args + return useCallback( + async ( + text: string, + images?: string[], + deadline?: number, + attachments?: readonly StructuredMobileSendAttachment[] + ): Promise => { + const currentFence = stateRef.current.fence + if (!client || !sessionId || !enabled || currentFence === null) { + onSendError('Message not sent (disconnected)') + return 'rejected' + } + const timeoutMs = timeoutForDeadline(deadline) + if (timeoutMs === null) { + onSendError('Message not sent') + return 'rejected' + } + if (attachments === undefined && images !== undefined && images.length > 0) { + onSendError('Message not sent') + return 'rejected' + } + const sendAttachments = attachments ?? [] + const commandOutcome = await dispatchMobileStructuredCommand({ + text, + hasAttachments: Boolean(sendAttachments.length || images?.length), + client, + sessionId, + fence: currentFence, + sessionKey, + pending: commandPending, + operationIds, + controller: { + agent: agent === 'claude' ? 'claude' : 'codex', + ...controller + }, + canRun: () => + !activeStructuredAgentSessionTurnId(stateRef.current.items) && + !stateRef.current.items.some( + (item) => pendingStructuredApproval(item) || pendingStructuredQuestion(item) + ), + onError: onSendError, + timeoutMs + }) + if (commandOutcome !== null) { + return commandOutcome + } + const body = structuredAgentSessionSendBody(text, sendAttachments) + if (body.blocks.length === 0) { + return 'rejected' + } + return sendMobileStructuredAgentSessionMessage({ + client, + sessionId, + sessionKey, + callerIdentity, + expectedRuntimeFence: currentFence, + text, + attachments: sendAttachments, + ...(queueCapable ? { delivery: 'queue-if-active' as const } : {}), + deadline, + onError: onSendError + }) + }, + [ + agent, + callerIdentity, + client, + commandPending, + controller, + enabled, + onSendError, + operationIds, + queueCapable, + sessionId, + sessionKey, + stateRef + ] + ) +}