diff --git a/mobile/src/session/__mocks__/mobile-prompt-controller.ts b/mobile/src/session/__mocks__/mobile-prompt-controller.ts index 934437f37e6..7e6e2b29d74 100644 --- a/mobile/src/session/__mocks__/mobile-prompt-controller.ts +++ b/mobile/src/session/__mocks__/mobile-prompt-controller.ts @@ -1,6 +1,8 @@ import './mobile-prompt-render-mocks' import { createElement } from 'react' import { Pressable } from 'react-native' +import type { NativeChatMessage } from '../../../../src/shared/native-chat-types' +import type { MobileNativeChatSendOutcome } from '../native-chat/mobile-native-chat-send' import type { RpcClient } from '../../transport/rpc-client' import type { RpcResponse, ConnectionState } from '../../transport/types' import type { MobileNativeChatTab } from '../native-chat/mobile-native-chat-eligibility' @@ -14,8 +16,15 @@ import type { AskAnswerSelection } from '../../../../src/shared/native-chat-ask- import { mobileNativeChatPromptDismissals } from '../native-chat/input/mobile-native-chat-prompt-dismissals' import { setMobileNativeChatDraftText } from '../native-chat/composer/mobile-native-chat-draft-store' export const visible = { value: true } -const session = { messages: [], folded: [], status: 'ready', transcriptLoading: false } -const structuredSession = { +const messages: NativeChatMessage[] = [] +export const session = { + messages, + folded: [], + status: 'ready', + transcriptLoading: false +} +export const structuredSession = { + asyncQuestionSourceScope: 'journal/host/session/epoch', session, isWorking: false, turnId: null, @@ -29,7 +38,7 @@ const structuredSession = { respondPermission: vi.fn(async () => true), respondQuestion: vi.fn(async () => true), cancelPrompt: vi.fn(async () => true), - sendWithOutcome: vi.fn(async () => 'accepted'), + sendWithOutcome: vi.fn<() => Promise>(async () => 'accepted'), cancel: vi.fn(), setStructuredOption: vi.fn(), invokeStructuredOption: vi.fn() @@ -124,12 +133,13 @@ type HarnessProps = { tab?: MobileNativeChatTab tabId?: string connState?: ConnectionState + hostId?: string } function Harness(props: HarnessProps) { - const { tab = baseTab, tabId = 'tab-1', connState = 'connected' } = props + const { tab = baseTab, tabId = 'tab-1', connState = 'connected', hostId = 'host' } = props const current = useMobileNativeChatController({ client, - hostId: 'host', + hostId, worktreeId: 'folder:work', activeSessionTab: tab, activeSessionTabId: tabId, @@ -197,6 +207,9 @@ export function reset(rpcClient: RpcClient) { visible.value = true handleRef.current = 'term-1' lastProps = null + session.messages = [] + structuredSession.asyncQuestionSourceScope = 'journal/host/session/epoch' + structuredSession.sendWithOutcome.mockReset().mockResolvedValue('accepted') sendError.mockReset() resolved.mockReset() mobileNativeChatPromptDismissals.clearForTests() diff --git a/mobile/src/session/mobile-prompt-controller.test.ts b/mobile/src/session/mobile-prompt-controller.test.ts index d6be2d32633..1eecd5dff23 100644 --- a/mobile/src/session/mobile-prompt-controller.test.ts +++ b/mobile/src/session/mobile-prompt-controller.test.ts @@ -1,10 +1,16 @@ import { act } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mobileNativeChatPromptDismissals } from './native-chat/input/mobile-native-chat-prompt-dismissals' +import { mobileStructuredSendDelivery } from './mobile-structured-send-delivery' +import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture' import type { RpcResponse } from '../transport/types' import type { RpcClient } from '../transport/rpc-client' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import type { AskPrompt } from '../../../src/shared/native-chat/input/native-chat-ask-types' import { visible, + session, + structuredSession, handleRef, baseTab, permissionTab, @@ -286,3 +292,323 @@ describe('prompt cards through the production controller, send contract and view vi.mock('./MobileQueuedMessageComposer', () => ({ MobileQueuedMessageComposer: 'QueuedMessageComposer' })) + +describe('async occurrences through the phone controller and rendered card actions', () => { + const row = (id: string, title = 'Q1?', callId?: string): NativeChatMessage => ({ + id, + role: 'assistant', + timestamp: null, + source: 'transcript', + blocks: [ + { + type: 'tool-call', + name: 'request_user_input_async', + callId, + input: { questions: [{ title, options: ['Red', 'Blue'] }] } + } + ] + }) + const structuredTab = { + type: 'agent-session', + id: 'tab-1', + title: 'Chat', + sessionId: 'session-1', + agent: 'codex', + isActive: true + } + const terminalTab = { + ...baseTab, + launchAgent: 'codex', + agentStatus: { ...baseTab.agentStatus, agentType: 'codex' } + } + const titles = () => getController().nativeChatAsk?.questions.map((q) => q.question) + + it.each(['structured', 'terminal'])( + 'keeps an agent notice answerable and retires only human input through the %s view', + async (kind) => { + const tab = kind === 'structured' ? structuredTab : terminalTab + const q1 = row('one') + const notice: NativeChatMessage = { + id: 'mail', + role: 'user', + timestamp: 2, + source: 'transcript', + blocks: [{ type: 'text', text: 'You have 1 orchestration message.' }], + from: { + kind: 'agent', + senders: [ + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Coder' + } + ], + orchestration: { + message: 'mail-notice', + mailbox: 'term_b', + dispatchId: null, + messages: [{ messageId: 'mail-1', runId: 'run-1', from: 'term_a' }] + } + } + } + session.messages = [q1, notice] + await render({ tab }) + expect(titles()).toEqual(['Q1?']) + await act(async () => { + await askAnswer([{ indices: [1] }]) + }) + if (kind === 'structured') { + expect(structuredSession.sendWithOutcome).toHaveBeenCalledWith('> Q1?\n\nBlue') + expect(titles()).toEqual(['Q1?']) + } else { + expect(client.sendRequest).toHaveBeenCalledWith( + 'terminal.send', + expect.objectContaining({ + text: '\x1b[200~> Q1?\r\rBlue\x1b[201~', + enter: true, + requireWriteSettlement: true + }), + expect.any(Object) + ) + } + const q2 = row('two', 'Q2?') + session.messages = [q1, notice, q2] + await render({ tab }) + expect(titles()).toContain('Q2?') + session.messages = [q1, notice, q2, { ...notice, id: 'human', from: undefined }] + await render({ tab }) + expect(titles()).toBeUndefined() + } + ) + + it.each(['structured', 'terminal'])( + 'retains exact cancelled calls through edits, prefix/ID replay, paging and new equal rows (%s)', + async (kind) => { + const tab = kind === 'structured' ? structuredTab : terminalTab + const q1 = row('one', 'Q1?', 'raw-call') + session.messages = [q1] + await render({ tab }) + await act(async () => { + await askCancel() + }) + expect(titles()).toBeUndefined() + for (const callId of ['renumbered', undefined]) { + session.messages = [ + { + ...row('one', 'Edited Q1?', callId), + blocks: [ + { type: 'text', text: 'prefix' }, + { type: 'tool-call', name: 'Bash', input: {} }, + ...row('one', 'Edited Q1?', callId).blocks, + ...row('one', 'Q2?').blocks + ] + } + ] + await render({ tab }) + expect(titles()).toEqual(['Q2?']) + } + await act(async () => { + await askCancel() + }) + session.messages = [] + await render({ tab }) + session.messages = [q1] + await render({ tab }) + expect(titles()).toBeUndefined() + await unmount() + await render({ tab }) + expect(titles()).toBeUndefined() + session.messages = [q1, row('new-equal-row')] + await render({ tab }) + expect(titles()).toEqual(['Q1?']) + expect(client.sendRequest).not.toHaveBeenCalled() + expect(structuredSession.sendWithOutcome).not.toHaveBeenCalled() + } + ) + + it.each(['accepted', 'queued', 'unknown', 'rejected'] as const)( + 'keeps structured %s admission separate from captured Cancel and delivered rows', + async (outcome) => { + let finish: (result: 'accepted' | 'queued' | 'unknown' | 'rejected') => void = () => { + throw new Error('not sent') + } + structuredSession.sendWithOutcome.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const q1 = row('one') + session.messages = [q1] + await render({ tab: structuredTab }) + let answer: Promise | undefined + act(() => { + answer = askAnswer([{ indices: [1] }]) + }) + session.messages = [q1, row('two', 'Q2?')] + await render({ tab: structuredTab }) + expect(titles()).toEqual(['Q1?', 'Q2?']) + await act(async () => { + finish(outcome) + await answer + }) + expect(titles()).toEqual(['Q1?', 'Q2?']) + session.messages = [ + q1, + row('two', 'Q2?'), + { + id: 'delivered', + role: 'user', + timestamp: null, + source: 'transcript', + blocks: [{ type: 'text', text: 'Blue' }] + } + ] + await render({ tab: structuredTab }) + expect(titles()).toBeUndefined() + expect(structuredSession.sendWithOutcome).toHaveBeenCalledWith('> Q1?\n\nBlue') + } + ) + + it('settles the terminal answer for Q1 after Q2 arrives without hiding Q2', async () => { + let finish: (reply: RpcResponse) => void = () => { + throw new Error('not sent') + } + vi.mocked(client.sendRequest).mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const q1 = row('one') + session.messages = [q1] + await render({ tab: terminalTab }) + let answer: Promise | undefined + await act(async () => { + answer = askAnswer([{ indices: [1] }]) + }) + session.messages = [q1, row('two', 'Q2?')] + await render({ tab: terminalTab }) + await act(async () => { + finish(response()) + await answer + }) + expect(titles()).toEqual(['Q2?']) + }) + + it.each(['source', 'reset', 'view'])( + 'fences a late captured structured answer after %s replacement', + async (replacement) => { + let finish: (outcome: 'accepted') => void = () => { + throw new Error('not sent') + } + structuredSession.sendWithOutcome.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + session.messages = [row('one')] + await render({ tab: structuredTab }) + let answer: Promise | undefined + act(() => { + answer = askAnswer([{ indices: [1] }]) + }) + if (replacement !== 'view') { + structuredSession.asyncQuestionSourceScope = `journal/${replacement}/session/epoch` + } + await render({ tab: structuredTab, tabId: replacement === 'view' ? 'tab-2' : 'tab-1' }) + await act(async () => { + finish('accepted') + await answer + }) + expect(titles()).toEqual(['Q1?']) + } + ) + it.each(['pending', 'rejected', 'withdrawn'] as const)( + 'does not turn real %s host-result conversion into an answered question', + async (kind) => { + const value = structuredSendResultFixture( + kind === 'pending' ? 'pending' : 'rejected', + kind === 'withdrawn' + ? 'This message was withdrawn before the agent started it.' + : 'provider_write_failed: broken pipe' + ) + if (kind === 'withdrawn' && 'submission' in value) { + value.submission.rejection = { kind: 'cancelled' } + } + const converted = mobileStructuredSendDelivery({ status: 'accepted', value }) + expect(converted.outcome).toBe('accepted') + structuredSession.sendWithOutcome.mockResolvedValue(converted.outcome) + session.messages = [row('one')] + await render({ tab: structuredTab }) + await act(async () => { + await expect(askAnswer([{ indices: [1] }])).resolves.toBe(true) + }) + expect(titles()).toEqual(['Q1?']) + } + ) + + it('keeps later same-ID Cancel after eviction/reentry and old answer settlement', async () => { + let finish: (outcome: 'accepted') => void = () => { + throw new Error('not sent') + } + structuredSession.sendWithOutcome.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + session.messages = [row('one')] + await render({ tab: structuredTab }) + let answer: Promise | undefined + act(() => { + answer = askAnswer([{ indices: [1] }]) + }) + act(() => { + for (let index = 0; index < 128; index += 1) { + mobileNativeChatPromptDismissals.hideAsync(`other-${index}`, ['other']) + } + }) + await unmount() + await render({ tab: structuredTab }) + await act(async () => { + await askCancel() + }) + await act(async () => { + finish('accepted') + await answer + }) + expect(titles()).toBeUndefined() + }) + + it('keeps structured answer controls after a thrown send', async () => { + structuredSession.sendWithOutcome.mockRejectedValue(new Error('send failed')) + session.messages = [row('one')] + await render({ tab: structuredTab }) + await act(async () => { + await expect(askAnswer([{ indices: [1] }])).rejects.toThrow('send failed') + }) + expect(titles()).toEqual(['Q1?']) + }) + + it('isolates equal portable IDs on two hosts and rejects the old host callback', async () => { + session.messages = [row('one')] + await render({ tab: structuredTab, hostId: 'host-A' }) + const ids = getController().nativeChatAsk?.occurrenceIds + const oldCancel = getController().dismissNativeChatAsk + await render({ tab: structuredTab, hostId: 'host-B' }) + expect(getController().nativeChatAsk?.occurrenceIds).toEqual(ids) + act(oldCancel) + expect(titles()).toEqual(['Q1?']) + await act(async () => { + await askCancel() + }) + await render({ tab: structuredTab, hostId: 'host-A' }) + expect(titles()).toEqual(['Q1?']) + await act(async () => { + await askCancel() + }) + await render({ tab: structuredTab, hostId: 'host-B' }) + expect(titles()).toBeUndefined() + }) +}) diff --git a/mobile/src/session/mobile-structured-agent-session-contract.ts b/mobile/src/session/mobile-structured-agent-session-contract.ts index 16ff9076a68..8be99e1f8ab 100644 --- a/mobile/src/session/mobile-structured-agent-session-contract.ts +++ b/mobile/src/session/mobile-structured-agent-session-contract.ts @@ -22,6 +22,7 @@ import type { MobileStructuredBackgroundTasks } from './use-mobile-structured-ba export type StructuredMobileSession = ReturnType & ReturnType & { session: MobileNativeChatSession + asyncQuestionSourceScope: string isWorking: boolean /** Desktop's Stop rule: a running turn, or conversation Stop while working or sending. */ canStop: boolean diff --git a/mobile/src/session/native-chat/input/MobileNativeChatPromptCard.test.tsx b/mobile/src/session/native-chat/input/MobileNativeChatPromptCard.test.tsx index fb4fcad84c2..e56ff797098 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatPromptCard.test.tsx +++ b/mobile/src/session/native-chat/input/MobileNativeChatPromptCard.test.tsx @@ -1,7 +1,10 @@ +import type { AskPrompt } from '../../../../../src/shared/native-chat/input/native-chat-ask-types' import { createElement } from 'react' import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' import { Pressable, TextInput } from 'react-native' import { describe, expect, it, vi } from 'vitest' +import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' +import { nativeChatAskDismissKey } from '../../../../../src/shared/native-chat-ask-dismiss-key' import { MobileNativeChatPromptCard } from './MobileNativeChatPromptCard' vi.mock('react-native', () => ({ @@ -88,4 +91,51 @@ describe('MobileNativeChatPromptCard collapse', () => { expect(textInput(tree!.root).props.value).toBe('turbo') act(() => tree!.unmount()) }) + it('resets stale pending choices/text on definition revision', async () => { + let tree: ReactTestRenderer | null = null + const render = (question: string) => { + const prompt: AskPrompt = { + ...ask, + replyAsMessage: true, + occurrenceIds: ['same-call'], + questions: [{ ...ask.questions[0]!, question }] + } + return createElement(MobileNativeChatPromptCard, { + ask: prompt, + askKey: nativeChatAskDismissKey(prompt) + }) + } + await act(async () => { + tree = create(render('Q1?')) + }) + act(() => tree!.root.findAll((node) => node.props.label === 'Other…')[0]!.props.onPress()) + act(() => textInput(tree!.root).props.onChangeText('old answer')) + await act(async () => tree!.update(render('Edited Q1?'))) + expect(tree!.root.findAllByType(TextInput)).toHaveLength(0) + expect(tree!.root.findAll((node) => node.props.selected === true)).toHaveLength(0) + act(() => tree!.unmount()) + }) + + it('resets typed answers on a host/view owner change with the same portable prompt', async () => { + const prompt: AskPrompt = { ...ask, replyAsMessage: true, occurrenceIds: ['same-portable-id'] } + function Harness({ host }: { host: string }) { + const dismissal = useMobileNativeChatAskDismiss({ + ask: prompt, + startedAt: null, + scopeKey: host, + sessionKey: 'view', + observing: true + }) + return createElement(MobileNativeChatPromptCard, { ask: prompt, askKey: dismissal.askKey }) + } + let tree: ReactTestRenderer | null = null + await act(async () => { + tree = create(createElement(Harness, { host: 'host-A' })) + }) + act(() => tree!.root.findAll((node) => node.props.label === 'Other…')[0]!.props.onPress()) + act(() => textInput(tree!.root).props.onChangeText('old host answer')) + await act(async () => tree!.update(createElement(Harness, { host: 'host-B' }))) + expect(tree!.root.findAllByType(TextInput)).toHaveLength(0) + act(() => tree!.unmount()) + }) }) diff --git a/mobile/src/session/native-chat/input/MobileNativeChatPromptCard.tsx b/mobile/src/session/native-chat/input/MobileNativeChatPromptCard.tsx index 327f29730eb..8a9a799e36c 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatPromptCard.tsx +++ b/mobile/src/session/native-chat/input/MobileNativeChatPromptCard.tsx @@ -77,7 +77,7 @@ export function MobileNativeChatPromptCard({ prompt={ask} onAnswer={async (selections) => { const accepted = (await onAnswerAsk?.(ask, selections)) ?? false - if (accepted) { + if (accepted && !ask.replyAsMessage) { onDismissAsk?.() } return accepted diff --git a/mobile/src/session/native-chat/input/use-mobile-native-chat-ask-dismiss.ts b/mobile/src/session/native-chat/input/use-mobile-native-chat-ask-dismiss.ts index 902a0d6adbd..e87698fc989 100644 --- a/mobile/src/session/native-chat/input/use-mobile-native-chat-ask-dismiss.ts +++ b/mobile/src/session/native-chat/input/use-mobile-native-chat-ask-dismiss.ts @@ -10,6 +10,7 @@ export function useMobileNativeChatAskDismiss(args: { scopeKey: string | null sessionKey: string | null observing: boolean + dismissAsync?: () => void }): { askKey: string | null showAsk: boolean @@ -24,13 +25,16 @@ export function useMobileNativeChatAskDismiss(args: { ) const dismiss = useMobileNativeChatPromptDismiss({ ...args, kind: 'ask', card }) return { - askKey: dismiss.content, + askKey: args.ask?.replyAsMessage + ? JSON.stringify([args.scopeKey, args.sessionKey, dismiss.content]) + : dismiss.content, showAsk: dismiss.showPrompt, collapsedAsk: dismiss.collapsed && args.ask ? { title: args.ask.questions[0]?.question ?? '', expand: dismiss.expandPrompt } : null, - dismissAsk: dismiss.dismissPrompt, + dismissAsk: + args.ask?.replyAsMessage && args.dismissAsync ? args.dismissAsync : dismiss.dismissPrompt, collapseAsk: dismiss.collapsePrompt } } diff --git a/mobile/src/session/native-chat/input/use-mobile-native-chat-prompt-dismiss.ts b/mobile/src/session/native-chat/input/use-mobile-native-chat-prompt-dismiss.ts index fe9b67f423f..10aec35f867 100644 --- a/mobile/src/session/native-chat/input/use-mobile-native-chat-prompt-dismiss.ts +++ b/mobile/src/session/native-chat/input/use-mobile-native-chat-prompt-dismiss.ts @@ -51,6 +51,8 @@ export function useMobileNativeChatPromptDismiss({ }, [sessionKey, occurrenceKey, scopeKey]) const dismissed = useSyncExternalStore(dismissals.subscribe, () => dismissals.read(storeKey)) const matches = + (!(card?.kind === 'question' && card.prompt.replyAsMessage) || + dismissed?.state === 'collapsed') && dismissed?.sessionKey === sessionKey && nativeChatPromptDismissalMatches(dismissed, content, startedAt) useLayoutEffect(() => { @@ -79,3 +81,29 @@ export function useMobileNativeChatPromptDismiss({ expandPrompt: () => dismissals.forget(storeKey) } } + +/** Keep addressed hides in the existing scoped owner, independently of the loaded definition. */ +export function useMobileAsyncQuestionHides( + scopeKey: string | null, + sourceKey: string, + viewKey: string +) { + const storeKey = JSON.stringify(['async', scopeKey, sourceKey]) + const owner = useMemo(() => ({ storeKey, viewKey }), [storeKey, viewKey]) + const active = useRef(null) + useLayoutEffect(() => { + active.current = owner + return () => { + active.current = null + } + }, [owner]) + const hidden = useSyncExternalStore(dismissals.subscribe, () => dismissals.readAsync(storeKey)) + return { + hidden, + capture: (ids: readonly string[]) => () => { + if (active.current === owner) { + dismissals.hideAsync(storeKey, ids) + } + } + } +} diff --git a/mobile/src/session/native-chat/input/use-mobile-native-chat-prompts.test.ts b/mobile/src/session/native-chat/input/use-mobile-native-chat-prompts.test.ts index eb8f9ddab2e..69d8ecaf797 100644 --- a/mobile/src/session/native-chat/input/use-mobile-native-chat-prompts.test.ts +++ b/mobile/src/session/native-chat/input/use-mobile-native-chat-prompts.test.ts @@ -1,8 +1,11 @@ +import { asyncQuestionSourceScope } from '../../../../../src/shared/native-chat/input/native-chat-ask' import { createElement } from 'react' import TestRenderer, { type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../../src/shared/agent-status-types' import type { NativeChatMessage } from '../../../../../src/shared/native-chat-types' +import { useMobileAsyncQuestionHides } from './use-mobile-native-chat-prompt-dismiss' +import { mobileNativeChatPromptDismissals } from './mobile-native-chat-prompt-dismissals' import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts' const renderers: ReactTestRenderer[] = [] @@ -31,6 +34,8 @@ function promptsFor( let captured: ReturnType | undefined function Probe(): null { captured = useMobileNativeChatPrompts({ + asyncSourceScope: 'host/session/epoch', + hiddenAsyncIds: new Set(), enabled: true, status: status as AgentStatusEntry | null, messages, @@ -171,6 +176,8 @@ describe('useMobileNativeChatPrompts in a structured chat', () => { let captured: ReturnType | undefined function Probe(): null { captured = useMobileNativeChatPrompts({ + asyncSourceScope: 'host/session/epoch', + hiddenAsyncIds: new Set(), enabled: true, structured: { permission: null, question: null, sendWithOutcome }, status: null, @@ -211,3 +218,80 @@ describe('useMobileNativeChatPrompts in a structured chat', () => { } ) }) + +describe('addressed phone hide subscriptions', () => { + it('keeps unrelated scopes still and reuses scanner output at N/10N', () => { + for (const count of [8, 80]) { + mobileNativeChatPromptDismissals.clearForTests() + let scans = 0 + const messages: NativeChatMessage[] = [ + { + ...askMessages[0]!, + get blocks(): NativeChatMessage['blocks'] { + scans += 1 + return [ + { + type: 'tool-call', + name: 'request_user_input_async', + input: { questions: [{ title: 'Q1?' }] } + } + ] + } + } + ] + const renders = Array(count).fill(0) + const actions: (() => void)[] = [] + function Probe({ index }: { index: number }): null { + renders[index] = (renders[index] ?? 0) + 1 + const sourceKey = asyncQuestionSourceScope({ + kind: 'journal', + agent: 'codex', + sessionId: `session-${Math.floor(index / 2)}`, + epoch: 'epoch' + }) + const hides = useMobileAsyncQuestionHides( + `host-${index % 2}/pane-${Math.floor(index / 2)}`, + sourceKey, + 'view' + ) + const prompts = useMobileNativeChatPrompts({ + enabled: true, + status: null, + messages, + transcriptLoading: false, + asyncSourceScope: sourceKey, + hiddenAsyncIds: hides.hidden + }) + actions[index] = hides.capture(prompts.ask?.occurrenceIds ?? []) + return null + } + let renderer: ReactTestRenderer + TestRenderer.act(() => { + renderer = TestRenderer.create( + createElement( + 'Probes', + null, + Array.from({ length: count }, (_, index) => createElement(Probe, { key: index, index })) + ) + ) + renderers.push(renderer) + }) + const initialScans = scans + expect(initialScans).toBeGreaterThanOrEqual(count) + expect(initialScans).toBeLessThanOrEqual(2 * count) + renders.fill(0) + TestRenderer.act(() => actions[0]!()) + expect(renders).toEqual([1, ...Array(count - 1).fill(0)]) + expect(scans).toBe(initialScans) + renders.fill(0) + TestRenderer.act(() => actions[1]!()) + expect(renders).toEqual([0, 1, ...Array(count - 2).fill(0)]) + expect(scans).toBe(initialScans) + renders.fill(0) + TestRenderer.act(() => actions[0]!()) + expect(renders.every((value) => value === 0)).toBe(true) + TestRenderer.act(() => renderer.unmount()) + renderers.pop() + } + }) +}) diff --git a/mobile/src/session/native-chat/input/use-mobile-native-chat-prompts.ts b/mobile/src/session/native-chat/input/use-mobile-native-chat-prompts.ts index be4aaab8737..4b0f9550b65 100644 --- a/mobile/src/session/native-chat/input/use-mobile-native-chat-prompts.ts +++ b/mobile/src/session/native-chat/input/use-mobile-native-chat-prompts.ts @@ -1,3 +1,7 @@ +import { + aggregateAsyncQuestions, + scanPendingAsks +} from '../../../../../src/shared/native-chat/input/native-chat-ask' import { useMemo } from 'react' import type { AgentStatusEntry } from '../../../../../src/shared/agent-status-types' import type { AskPrompt } from '../../../../../src/shared/native-chat/input/native-chat-ask-types' @@ -30,8 +34,20 @@ export function useMobileNativeChatPrompts(args: { messages: readonly NativeChatMessage[] // Unsettled/retained reconnect history may contain answered asks and cannot supply current prompt cards. transcriptLoading: boolean + asyncSourceScope: string + hiddenAsyncIds: ReadonlySet }): MobileNativeChatPrompts { const { enabled, structured = null, status, messages, transcriptLoading } = args + const scan = useMemo( + () => (!transcriptLoading ? scanPendingAsks(messages) : null), + [messages, transcriptLoading] + ) + const pendingAsk = useMemo( + () => + aggregateAsyncQuestions(scan?.contributors ?? [], args.asyncSourceScope, args.hiddenAsyncIds), + [scan, args.asyncSourceScope, args.hiddenAsyncIds] + ) + const transcriptAsk = scan?.blocking ?? pendingAsk const interactivePrompt = status?.interactivePrompt const toolName = status?.toolName const agent = status?.agentType @@ -42,13 +58,14 @@ export function useMobileNativeChatPrompts(args: { toolName, agent, messages, + transcriptAsk, transcriptSettled: !transcriptLoading }), - [interactivePrompt, toolName, agent, messages, transcriptLoading] + [interactivePrompt, toolName, agent, messages, transcriptLoading, transcriptAsk] ) const structuredAsk = useMobileStructuredAsyncQuestion({ structured, - messages, + pendingAsk, transcriptLoading }) if (structured) { 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 71678e87e17..8f521bc812d 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -91,6 +91,7 @@ vi.mock('./use-mobile-native-chat-session', () => ({ })) vi.mock('./use-mobile-structured-agent-session', () => ({ useMobileStructuredAgentSession: () => ({ + asyncQuestionSourceScope: 'journal/host/session/epoch', session: structuredSessionState, ...structuredActivity, queued: { cards: [], send: vi.fn(), delete: vi.fn(), edit: vi.fn() }, diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 0f130dbcada..1fae8abe0ff 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -1,3 +1,4 @@ +import { asyncQuestionSourceScope } from '../../../src/shared/native-chat/input/native-chat-ask' import { useLayoutEffect, useRef, type MutableRefObject } from 'react' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' @@ -134,6 +135,14 @@ export function useMobileNativeChatController(args: { scopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId), sessionId: activeChatSessionId, streamIdentity, + asyncSourceScope: activeChatStructured + ? structuredNativeChat.asyncQuestionSourceScope + : asyncQuestionSourceScope({ + kind: 'transcript', + agent: activeChatAgent, + sessionId: activeChatSessionId, + transcriptPath: activeChatResolution?.transcriptPath ?? null + }), visible: showNativeChat, terminal: bridgePromptWrites, onSendResolved diff --git a/mobile/src/session/use-mobile-native-chat-prompt-controller.ts b/mobile/src/session/use-mobile-native-chat-prompt-controller.ts index 6ec9315496c..c4c176576ab 100644 --- a/mobile/src/session/use-mobile-native-chat-prompt-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-prompt-controller.ts @@ -1,3 +1,6 @@ +import { useMobileAsyncQuestionHides } from './native-chat/input/use-mobile-native-chat-prompt-dismiss' +import type { AskPrompt } from '../../../src/shared/native-chat/input/native-chat-ask-types' +import type { AskAnswerSelection } from '../../../src/shared/native-chat-ask-answer' import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' import type { MobileNativeChatController } from './mobile-native-chat-controller-contract' import type { useMobileBridgeChatPromptWrites } from './use-mobile-bridge-chat-prompt-writes' @@ -17,6 +20,7 @@ export function useMobileNativeChatPromptController({ scopeKey, sessionId, streamIdentity, + asyncSourceScope, visible, terminal, onSendResolved @@ -31,6 +35,7 @@ export function useMobileNativeChatPromptController({ scopeKey: string | null sessionId: string | null streamIdentity: string + asyncSourceScope: string visible: boolean /** Terminal-bridge writes; a structured chat answers through its own session. */ terminal: Pick< @@ -55,6 +60,7 @@ export function useMobileNativeChatPromptController({ | 'handleNativeChatAnswerAsk' | 'handleNativeChatCancelAsk' > & { hasPromptCard: boolean } { + const asyncHides = useMobileAsyncQuestionHides(scopeKey, asyncSourceScope, streamIdentity) const { permission: legacyNativeChatPermission, waitStartedAt: nativeChatPromptStartedAt, @@ -65,20 +71,31 @@ export function useMobileNativeChatPromptController({ structured, status, messages: session.messages, - transcriptLoading: session.transcriptLoading + transcriptLoading: session.transcriptLoading, + asyncSourceScope, + hiddenAsyncIds: asyncHides.hidden }) // A never-read transcript cannot prove that a dismissed prompt cleared. const nativeChatTranscriptSettled = session.status === 'ready' || (session.status === 'error' && session.messages.length > 0) const askDismissal = useMobileNativeChatAskDismiss({ ask: nativeChatAskPrompt, + dismissAsync: asyncHides.capture(nativeChatAskPrompt?.occurrenceIds ?? []), startedAt: nativeChatPromptStartedAt, scopeKey, - sessionKey: sessionId, + sessionKey: nativeChatAskPrompt?.replyAsMessage ? streamIdentity : sessionId, observing: visible && (nativeChatAskPrompt != null || nativeChatTranscriptSettled) }) const answerAsk = useNativeChatAcceptedAction( - structuredAnswer ?? terminal.answerAsk, + async (prompt: AskPrompt, selections: AskAnswerSelection[]) => { + const hide = asyncHides.capture(prompt.occurrenceIds ?? []) + const accepted = await (structuredAnswer ?? terminal.answerAsk)(prompt, selections) + // Only terminal writes acknowledge delivery; structured admission leaves retirement to rows. + if (accepted && prompt.replyAsMessage && !structuredAnswer) { + hide() + } + return accepted + }, onSendResolved ) const cancelAsk = useNativeChatAcceptedAction(terminal.cancelAsk, onSendResolved) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 9ea1b5042e4..ba86e0a6a91 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -1,3 +1,4 @@ +import { asyncQuestionSourceScope as questionSourceScope } from '../../../src/shared/native-chat/input/native-chat-ask' import { useCallback, useMemo, useRef } from 'react' import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat/transcript/native-chat-transcript-retention' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' @@ -206,7 +207,7 @@ export function useMobileStructuredAgentSession( sendSelection: options.sendSelection, takesSkills: hostSupport?.sendSkills === true }) - const { items, currentRequests, actionablePromptIds, status } = state + const { items, currentRequests, actionablePromptIds, status, epoch } = state // Prefer host actionability: ended-agent prompts must receive no answer. const [approvalPrompt, questionPrompt] = useMemo(() => { const prompts = { items, currentRequests, actionablePromptIds } @@ -273,6 +274,7 @@ export function useMobileStructuredAgentSession( return { ...options, visualSource, + asyncQuestionSourceScope: questionSourceScope({ kind: 'journal', agent, sessionId, epoch }), imagePreviewsByMessageId: sends.imagePreviewsByMessageId, session: { messages, diff --git a/mobile/src/session/use-mobile-structured-async-question.ts b/mobile/src/session/use-mobile-structured-async-question.ts index b36956c6188..d9ebef904f2 100644 --- a/mobile/src/session/use-mobile-structured-async-question.ts +++ b/mobile/src/session/use-mobile-structured-async-question.ts @@ -1,11 +1,9 @@ -import { useCallback, useMemo } from 'react' -import { extractPendingMessageReplyAsk } from '../../../src/shared/native-chat/input/native-chat-ask' +import { useCallback } from 'react' import { formatAskAnswer, type AskAnswerSelection } from '../../../src/shared/native-chat-ask-answer' import type { AskPrompt } from '../../../src/shared/native-chat/input/native-chat-ask-types' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import type { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' export type MobileStructuredAskSession = Pick< @@ -16,20 +14,17 @@ export type MobileStructuredAskSession = Pick< // Keep nonblocking structured questions pending until the next user message; answer by ordinary send without touching the draft. export function useMobileStructuredAsyncQuestion(args: { structured: MobileStructuredAskSession | null - messages: readonly NativeChatMessage[] + pendingAsk: AskPrompt | null transcriptLoading: boolean }): { ask: AskPrompt | null answer: ((prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise) | null } { - const { structured, messages, transcriptLoading } = args + const { structured, pendingAsk, transcriptLoading } = args // A journal prompt takes the card slot first, as on desktop. const readable = structured !== null && !structured.permission && !structured.question && !transcriptLoading - const ask = useMemo( - () => (readable ? extractPendingMessageReplyAsk(messages) : null), - [readable, messages] - ) + const ask = readable ? pendingAsk : null const sendWithOutcome = structured?.sendWithOutcome const answer = useCallback( async (prompt: AskPrompt, selections: AskAnswerSelection[]) => diff --git a/src/main/codex/codex-structured-async-question.test.ts b/src/main/codex/codex-structured-async-question.test.ts index 32374c67176..f11923d6828 100644 --- a/src/main/codex/codex-structured-async-question.test.ts +++ b/src/main/codex/codex-structured-async-question.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from 'vitest' -import { extractPendingAsk } from '../../shared/native-chat/input/native-chat-ask' -import { codexItemBody } from './codex-structured-item-translation' +import { + extractPendingAsk, + scanPendingAsks, + asyncQuestionOccurrenceId +} from '../../shared/native-chat/input/native-chat-ask' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { + codexItemBody, + codexItemIdentity, + CodexTurnOrdinals +} from './codex-structured-item-translation' describe('Codex async question items', () => { it('carries an async question on its message so clients can answer it', () => { @@ -34,4 +44,41 @@ describe('Codex async question items', () => { ] }) }) + + it('keeps a resumed async item dismissed when provider IDs renumber, but identifies later rows', () => { + function project(rawId: string, ordinals: CodexTurnOrdinals): NativeChatMessage { + const item = { + type: 'agentMessage', + id: rawId, + text: 'Which color?', + delivery: 'async', + questions: [{ title: 'Which color?', options: ['Red', 'Blue'] }] + } + const body = codexItemBody(item) + if (body?.kind !== 'message') { + throw new Error('Expected an async message') + } + return { + id: agentJournalItemKey( + codexItemIdentity({ threadId: 'thread', turnId: 'turn', item, ordinals }) + ), + role: 'assistant', + blocks: body.blocks, + timestamp: null, + source: 'transcript' + } + } + const liveOrdinals = new CodexTurnOrdinals() + const live = project('live-call-async', liveOrdinals) + const resumed = project('item-0', new CodexTurnOrdinals()) + const key = (row: NativeChatMessage) => + asyncQuestionOccurrenceId( + 'journal/host/thread/epoch', + scanPendingAsks([row]).contributors[0]! + ) + expect(live.id).toBe(resumed.id) + expect(key(live)).not.toBeNull() + expect(key(resumed)).toBe(key(live)) + expect(key(project('later-call-async', liveOrdinals))).not.toBe(key(live)) + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx index 127578df1a9..539745b5f2e 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx @@ -39,6 +39,8 @@ import { NativeChatInteractiveCard } from './NativeChatInteractiveCard' import { TooltipProvider } from '@/components/ui/tooltip' import { useNativeChatInteractivePromptCard } from './use-native-chat-interactive-prompt-card' import { useNativeChatPromptCardPresentation } from './use-native-chat-prompt-card-presentation' +import { asyncQuestionSourceScope } from '../../../../shared/native-chat/input/native-chat-ask' +import { useNativeChatAsyncQuestions } from './use-native-chat-async-questions' import { nativeChatPromptDismissals } from './native-chat-prompt-dismissals' // Answered occurrences outlive a view by design; each test starts with none. @@ -117,29 +119,50 @@ function CardHarness({ canSend, messages, transcriptSettled, - targetPtyId = 'pty-1' + targetPtyId = 'pty-1', + sourceKey = 'transcript/host/session' }: { canSend: boolean messages?: readonly NativeChatMessage[] transcriptSettled: boolean targetPtyId?: string + sourceKey?: string }): React.JSX.Element { + const asyncQuestions = useNativeChatAsyncQuestions({ + paneKey: 'tab-1:leaf-1', + sourceKey, + sourceScope: asyncQuestionSourceScope({ + kind: 'transcript', + agent: 'codex', + sessionId: 'session', + transcriptPath: null + }), + viewKey: targetPtyId, + messages: messages ?? NO_MESSAGES, + settled: transcriptSettled && messages !== undefined + }) const card = useNativeChatInteractivePromptCard({ paneKey: 'tab-1:leaf-1', messages: messages ?? NO_MESSAGES, + transcriptAsk: asyncQuestions.blocking ?? asyncQuestions.prompt, transcriptSettled: transcriptSettled && messages !== undefined }) const presentation = useNativeChatPromptCardPresentation({ paneKey: 'tab-1:leaf-1', targetPtyId, card, + dismissAsync: asyncQuestions.dismiss, canSend }) return ( {presentation.card ? ( { expect(screen.queryByText('Tabs or spaces?')).not.toBeInTheDocument() }) }) + +describe('terminal async contributors through the production hooks and card', () => { + const row = (id: string, title: string): NativeChatMessage => ({ + id, + role: 'assistant', + timestamp: null, + source: 'transcript', + blocks: [ + { + type: 'tool-call', + name: 'request_user_input_async', + input: { questions: [{ title, options: ['Tabs', 'Spaces'] }] } + } + ] + }) + beforeEach(() => { + vi.clearAllMocks() + paneStatus.interactivePrompt = undefined + paneStatus.toolName = undefined + paneStatus.state = 'done' + }) + afterEach(cleanup) + + it('retains terminal choices across an agent notice until legacy human input', () => { + const q1 = row('one', 'Q1?') + const notice: NativeChatMessage = { + id: 'mail', + role: 'user', + timestamp: 2, + source: 'transcript', + blocks: [{ type: 'text', text: 'You have 1 orchestration message.' }], + from: { + kind: 'agent', + senders: [ + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Coder' + } + ], + orchestration: { + message: 'mail-notice', + mailbox: 'term_b', + dispatchId: null, + messages: [{ messageId: 'mail-1', runId: 'run-1', from: 'term_a' }] + } + } + } + const view = render(cardElement(true, [q1])) + fireEvent.click(screen.getByRole('button', { name: /Spaces/ })) + view.rerender(cardElement(true, [q1, notice])) + expect(screen.getByText('Q1?')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Spaces/ })).toHaveAttribute('aria-pressed', 'true') + view.rerender(cardElement(true, [q1, notice, { ...notice, id: 'human', from: undefined }])) + expect(composerShown()).toBe(true) + expect(mocks.sendAnswer).not.toHaveBeenCalled() + }) + + it('filters Q1 before grouping Q2 and never writes Cancel, including window movement', () => { + const q1 = row('one', 'Q1?') + const q2 = row('two', 'Q2?') + const view = render(cardElement(true, [q1])) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + view.rerender(cardElement(true, [q1, q2])) + expect(screen.queryByText('Q1?')).not.toBeInTheDocument() + expect(screen.getByText('Q2?')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + view.rerender(cardElement(true, [])) + view.rerender(cardElement(true, [row('one', 'Edited Q1?'), q2])) + expect(composerShown()).toBe(true) + expect(mocks.cancelAsk).not.toHaveBeenCalled() + expect(mocks.cancel).not.toHaveBeenCalled() + expect(mocks.sendRawVerified).not.toHaveBeenCalled() + // Async hides cannot refuse a live blocking prompt with the same definition. + paneStatus.interactivePrompt = JSON.stringify({ + questions: [{ question: 'Q1?', options: ['Tabs', 'Spaces'] }] + }) + view.rerender(cardElement(true, [q1, q2])) + expect(screen.getByText('Q1?')).toBeInTheDocument() + }) + + it.each(['same-owner', 'source', 'PTY'])( + 'captures answered Q1 IDs while Q2 arrives (%s)', + (replacement) => { + let finish: (delivered: boolean) => void = () => { + throw new Error('not submitted') + } + mocks.sendAnswer.mockImplementation((_prompt, _selections, done) => { + finish = done! + return { settleAfterMs: 500 } + }) + const q1 = row('one', 'Q1?') + const q2 = row('two', 'Q2?') + const element = ( + messages: NativeChatMessage[], + sourceKey = 'transcript/host/session', + targetPtyId = 'pty-1' + ) => ( + + ) + const view = render(element([q1])) + chooseSpacesAndSubmit() + view.rerender( + element( + [q1, q2], + replacement === 'source' ? 'transcript/other/session' : undefined, + replacement === 'PTY' ? 'pty-2' : undefined + ) + ) + act(() => finish(true)) + if (replacement === 'same-owner') { + expect(screen.getByText('Q2?')).toBeInTheDocument() + expect(screen.queryByText('Q1?')).not.toBeInTheDocument() + } else { + expect(screen.getByText('Q1?')).toBeInTheDocument() + } + } + ) + + it('resets an edited pending form without unmounting its answer owner', () => { + const view = render(cardElement(true, [row('one', 'Q1?')])) + fireEvent.click(screen.getByRole('button', { name: /Spaces/ })) + expect(screen.getByRole('button', { name: /Spaces/ })).toHaveAttribute('aria-pressed', 'true') + view.rerender(cardElement(true, [row('one', 'Revised Q1?')])) + expect(screen.getByRole('button', { name: /Spaces/ })).toHaveAttribute('aria-pressed', 'false') + expect(mocks.cancelPending).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx index 1656cf77568..391b920de87 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx @@ -1,3 +1,4 @@ +import { nativeChatAskDismissKey } from '../../../../shared/native-chat-ask-dismiss-key' import { useCallback, useLayoutEffect, useRef, useState } from 'react' import type { InteractivePromptCard } from '../../../../shared/native-chat-interactive-prompt' import { NativeChatQuestionCard } from './NativeChatQuestionCard' @@ -81,6 +82,7 @@ export function NativeChatInteractiveCard({ } return ( ({ }) })) // A pending question, so the pane mounts its card with the send it hands it. -vi.mock('./use-native-chat-interactive-prompt-card', () => ({ - useNativeChatInteractivePromptCard: () => ({ kind: 'question', prompt }) -})) +vi.mock('./use-native-chat-interactive-prompt-card', async (importOriginal) => { + const original = await importOriginal() + const { useNativeChatPromptCardPresentation } = + await import('./use-native-chat-prompt-card-presentation') + return { + useNativeChatTerminalPromptCard: ( + args: Parameters[0] + ) => { + const card = { kind: 'question' as const, prompt } + const presentation = useNativeChatPromptCardPresentation({ ...args, card }) + return { promptCard: card, presentation, actionKey: presentation.occurrenceKey ?? 'prompt' } + } + } +}) vi.mock('./NativeChatInteractiveCard', async (importOriginal) => { const original = await importOriginal() return { diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index eec1b20b54e..086b8fd1de2 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -20,8 +20,7 @@ import { NativeChatFindBar } from './NativeChatFindBar' import { useNativeChatCanSend } from './use-native-chat-can-send' import { NativeChatInteractiveCard } from './NativeChatInteractiveCard' import { nativeChatAskRequiresTerminal } from '../../../../shared/native-chat-agent-support' -import { useNativeChatInteractivePromptCard } from './use-native-chat-interactive-prompt-card' -import { useNativeChatPromptCardPresentation } from './use-native-chat-prompt-card-presentation' +import { useNativeChatTerminalPromptCard } from './use-native-chat-interactive-prompt-card' import { NativeChatPromptStrip } from './NativeChatPromptCollapse' import { NativeChatEmptyState } from './NativeChatEmptyState' import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send' @@ -188,18 +187,20 @@ export function NativeChatResolvedView({ : new Map([...(launchPromptDeliveryNotices ?? []), ...delivery.notices]), [launchPromptDeliveryNotices, delivery.notices] ) - const promptCard = useNativeChatInteractivePromptCard({ - paneKey, - messages: sessionAfterCommandBoundaries.messages, - transcriptSettled: session.readPhase === 'ready' - }) - // A prompt card and composer must never share the input region: composer text would answer the agent’s selector. - const promptCardPresentation = useNativeChatPromptCardPresentation({ + const { + promptCard, + presentation: promptCardPresentation, + actionKey + } = useNativeChatTerminalPromptCard({ paneKey, targetPtyId, - card: promptCard, - canSend, - transcriptSettled: session.readPhase === 'ready' + runtimeEnvironmentId, + agent, + sessionId, + transcriptPath: transcriptPath ?? null, + messages: sessionAfterCommandBoundaries.messages, + transcriptSettled: session.readPhase === 'ready', + canSend }) const { card: shownPromptCard, collapsedCard } = promptCardPresentation const mountedPromptCard = shownPromptCard ?? collapsedCard @@ -378,7 +379,7 @@ export function NativeChatResolvedView({ {mountedPromptCard ? (