From b128cbbd6ac667cd2843d60f903acd115d031f7c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:04:38 -0700 Subject: [PATCH] fix(chat): route OMP question cards to terminal (#31440) Route terminal-backed OMP question cards to the owning terminal and refuse automated ask answer/cancel writes. Preserve structured semantic answers and recognize OMP multi-select metadata. Verified against the real OMP selector source; automatic semantic card answering remains tracked in #26692. Co-authored-by: jairuspace Co-authored-by: Daniel Lopez --- .../session/MobileNativeChatOverlay.test.ts | 40 +++++- .../src/session/MobileNativeChatOverlay.tsx | 11 ++ .../MobileNativeChatPromptCard.test.tsx | 37 ++++++ .../session/MobileNativeChatPromptCard.tsx | 121 ++++++++++++------ ...NativeChatView.prompt-presentation.test.ts | 20 +++ mobile/src/session/MobileNativeChatView.tsx | 6 + .../session/MobileSessionActiveContent.tsx | 1 + .../use-mobile-bridge-chat-prompt-writes.ts | 1 + ...mobile-native-chat-answer-send-omp.test.ts | 119 +++++++++++++++++ .../use-mobile-native-chat-answer-send.ts | 9 +- .../use-mobile-native-chat-cancel-ask.test.ts | 12 +- .../use-mobile-native-chat-cancel-ask.ts | 11 +- .../NativeChatInteractiveCard.test.tsx | 36 ++++++ .../native-chat/NativeChatInteractiveCard.tsx | 30 +++++ ...iveChatResolvedView.reveal-latest.test.tsx | 51 +++++++- .../native-chat/NativeChatResolvedView.tsx | 15 ++- .../use-native-chat-interactive-send.test.tsx | 17 +++ .../use-native-chat-interactive-send.ts | 11 +- src/renderer/src/i18n/locales/en.json | 4 +- src/shared/native-chat-agent-support.ts | 5 + src/shared/native-chat-ask.test.ts | 11 ++ src/shared/native-chat-ask.ts | 3 +- 22 files changed, 511 insertions(+), 60 deletions(-) create mode 100644 mobile/src/session/use-mobile-native-chat-answer-send-omp.test.ts diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index 2ec406f5990..990e39d606b 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -36,6 +36,10 @@ type Tick = { /** The host says a person's Stop is ending the turn. */ stopping?: boolean acceptsImages?: boolean + agent?: string + structured?: boolean + activeTabId?: string | null + setTabView?: (tabId: string, view: 'terminal') => void } function overlayElement(tick: Tick): ReturnType { @@ -43,7 +47,9 @@ function overlayElement(tick: Tick): ReturnType { const controller = { showNativeChat: tick.show ?? true, nativeChatSession: { messages: tick.messages ?? [], status: 'ready' }, - nativeChatAgent: 'claude', + nativeChatAgent: tick.agent ?? 'claude', + nativeChatStructured: tick.structured ?? false, + setTabView: tick.setTabView ?? vi.fn(), nativeChatAcceptsImages: tick.acceptsImages ?? true, nativeChatAgentWorking: tick.streamLive ?? false, nativeChatTurnIndicator: { @@ -70,6 +76,7 @@ function overlayElement(tick: Tick): ReturnType { } as unknown as MobileNativeChatController return createElement(MobileNativeChatOverlay, { controller, + activeSessionTabId: tick.activeTabId, images: {} as never, onMicPress: vi.fn(), micActive: false, @@ -238,6 +245,37 @@ describe('MobileNativeChatOverlay attach button', () => { renderer = null }) + it('opens the exact active terminal tab for an OMP bridge question', async () => { + const setTabView = vi.fn() + await act(async () => { + renderer = create(overlayElement({ agent: 'omp', activeTabId: 'tab-omp', setTabView })) + }) + const view = renderer!.root.find((node) => node.type === 'ChatView') + expect(view.props.askRequiresTerminal).toBe(true) + act(() => view.props.onAnswerInTerminal()) + expect(setTabView).toHaveBeenCalledExactlyOnceWith('tab-omp', 'terminal') + }) + + it.each(['structured', 'claude', 'missing tab'])( + 'keeps the terminal-only gate scoped correctly: %s', + async (scenario) => { + await act(async () => { + renderer = create( + overlayElement({ + agent: scenario === 'claude' ? 'claude' : 'omp', + structured: scenario === 'structured', + activeTabId: scenario === 'missing tab' ? null : 'tab-omp' + }) + ) + }) + const view = renderer!.root.find((node) => node.type === 'ChatView') + expect(view.props.askRequiresTerminal).toBe(scenario === 'missing tab') + if (scenario === 'missing tab') { + expect(view.props.onAnswerInTerminal).toBeUndefined() + } + } + ) + it.each([true, false])('offers attach only when the agent takes images (%s)', async (accepts) => { await act(async () => { renderer = create(overlayElement({ acceptsImages: accepts })) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 1ec05faa668..e860ea45fd8 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -17,9 +17,11 @@ import { useMobileNativeChatReplyReveals } from './mobile-native-chat-reply-reveals' import { MobileNativeChatRowOverlaysHost } from './MobileNativeChatRowOverlaysHost' +import { nativeChatAskRequiresTerminal } from '../../../src/shared/native-chat-agent-support' type Props = { controller: MobileNativeChatController + activeSessionTabId?: string | null /** Opens a tapped file reference (worktree-relative or absolute, optional * :line(:col) suffix) through the shared tap-to-open flow. */ onOpenFile: (pathText: string) => void @@ -53,6 +55,7 @@ type Props = { * the chat list below it does not. */ export function MobileNativeChatOverlay({ controller, + activeSessionTabId, onOpenFile, images, structuredComposer = null, @@ -146,6 +149,14 @@ export function MobileNativeChatOverlay({ promptKey={controller.nativeChatPromptKey} onDismissAsk={controller.dismissNativeChatAsk} onAnswerAsk={controller.handleNativeChatAnswerAsk} + askRequiresTerminal={ + !controller.nativeChatStructured && nativeChatAskRequiresTerminal(agent) + } + onAnswerInTerminal={ + activeSessionTabId + ? () => controller.setTabView(activeSessionTabId, 'terminal') + : undefined + } onCancelAsk={controller.handleNativeChatCancelAsk} onCancelPrompt={controller.handleNativeChatCancelPrompt} onCollapseAsk={controller.collapseNativeChatAsk} diff --git a/mobile/src/session/MobileNativeChatPromptCard.test.tsx b/mobile/src/session/MobileNativeChatPromptCard.test.tsx index 48ddd657141..2bd9bb145b6 100644 --- a/mobile/src/session/MobileNativeChatPromptCard.test.tsx +++ b/mobile/src/session/MobileNativeChatPromptCard.test.tsx @@ -1,5 +1,6 @@ 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 { MobileNativeChatPromptCard } from './MobileNativeChatPromptCard' @@ -27,6 +28,42 @@ const textInput = (root: ReactTestInstance) => const ask = { questions: [{ question: 'Pick?', options: [{ label: 'East' }], multiSelect: false }] } describe('MobileNativeChatPromptCard collapse', () => { + it.each([true, false])( + 'shows only terminal navigation when answering requires it (%s)', + async (available) => { + const onAnswerInTerminal = vi.fn() + const onAnswerAsk = vi.fn(async () => true) + const onCancelAsk = vi.fn(async () => true) + const onDismissAsk = vi.fn() + let tree: ReactTestRenderer | null = null + await act(async () => { + tree = create( + createElement(MobileNativeChatPromptCard, { + ask, + requiresTerminal: true, + onAnswerAsk, + onCancelAsk, + onDismissAsk, + onAnswerInTerminal: available ? onAnswerInTerminal : undefined + }) + ) + }) + const buttons = tree!.root.findAllByType(Pressable) + expect(buttons).toHaveLength(1) + expect(buttons[0]!.props.accessibilityLabel).toBe('Answer in terminal') + expect(buttons[0]!.props.disabled).toBe(!available) + expect(tree!.root.findAllByType(TextInput)).toHaveLength(0) + if (available) { + act(() => buttons[0]!.props.onPress()) + } + expect(onAnswerInTerminal).toHaveBeenCalledTimes(available ? 1 : 0) + expect(onAnswerAsk).not.toHaveBeenCalled() + expect(onCancelAsk).not.toHaveBeenCalled() + expect(onDismissAsk).not.toHaveBeenCalled() + act(() => tree!.unmount()) + } + ) + it('keeps a partly answered Ask mounted but hidden while collapsed', async () => { let tree: ReactTestRenderer | null = null const render = (collapsed: boolean) => diff --git a/mobile/src/session/MobileNativeChatPromptCard.tsx b/mobile/src/session/MobileNativeChatPromptCard.tsx index 210c9183962..cbb1ce6d7ca 100644 --- a/mobile/src/session/MobileNativeChatPromptCard.tsx +++ b/mobile/src/session/MobileNativeChatPromptCard.tsx @@ -1,4 +1,5 @@ -import { StyleSheet, View } from 'react-native' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask' import { MobileNativeChatAsk } from './MobileNativeChatAsk' import { MobileNativeChatPermission } from './MobileNativeChatPermission' @@ -16,6 +17,8 @@ export function MobileNativeChatPromptCard({ askKey, onDismissAsk, onAnswerAsk, + requiresTerminal = false, + onAnswerInTerminal, onCancelAsk, onCancelPrompt, onCollapseAsk, @@ -30,6 +33,8 @@ export function MobileNativeChatPromptCard({ askKey?: string | null onDismissAsk?: () => void onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise + requiresTerminal?: boolean + onAnswerInTerminal?: () => void onCancelAsk?: () => Promise onCancelPrompt?: (prompt?: NonNullable) => Promise onCollapseAsk?: () => void @@ -42,43 +47,58 @@ export function MobileNativeChatPromptCard({ question?: MobileChatQuestion | null onAnswerQuestion?: (text: string) => Promise }): React.JSX.Element | null { - const card = ask ? ( - { - const accepted = (await onAnswerAsk?.(ask, selections)) ?? false - if (accepted) { - onDismissAsk?.() - } - return accepted - }} - onCancel={async () => { - const accepted = (await onCancelAsk?.()) ?? false - if (accepted) { - onDismissAsk?.() - } - return accepted - }} - onCollapse={onCollapseAsk} - /> - ) : permission ? ( - (await onRespondPermission?.(send)) ?? false} - onCancel={onCancelPrompt} - onCollapse={onCollapsePrompt} - /> - ) : question ? ( - (await onAnswerQuestion?.(text)) ?? false} - onCancel={onCancelPrompt} - onCollapse={onCollapsePrompt} - /> - ) : null + const card = + ask && requiresTerminal ? ( + + Answer this OMP question in the terminal. + + Answer in terminal + + + ) : ask ? ( + { + const accepted = (await onAnswerAsk?.(ask, selections)) ?? false + if (accepted) { + onDismissAsk?.() + } + return accepted + }} + onCancel={async () => { + const accepted = (await onCancelAsk?.()) ?? false + if (accepted) { + onDismissAsk?.() + } + return accepted + }} + onCollapse={onCollapseAsk} + /> + ) : permission ? ( + (await onRespondPermission?.(send)) ?? false} + onCancel={onCancelPrompt} + onCollapse={onCollapsePrompt} + /> + ) : question ? ( + (await onAnswerQuestion?.(text)) ?? false} + onCancel={onCancelPrompt} + onCollapse={onCollapsePrompt} + /> + ) : null // Why one stable wrapper: collapsing hides the card without remounting it, so a partly answered // card keeps its selections. return ( @@ -94,4 +114,27 @@ export function MobileNativeChatPromptCard({ ) } -const styles = StyleSheet.create({ collapsed: { display: 'none' } }) +const styles = StyleSheet.create({ + collapsed: { display: 'none' }, + terminalCard: { + backgroundColor: colors.bgPanel, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle, + padding: spacing.md, + gap: spacing.sm + }, + explanation: { color: colors.textPrimary, fontSize: typography.bodySize }, + terminalButton: { + alignSelf: 'flex-start', + minHeight: 44, + justifyContent: 'center', + backgroundColor: colors.surfaceBright, + borderRadius: radii.button, + paddingHorizontal: spacing.md + }, + terminalButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + } +}) diff --git a/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts b/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts index e320c78a53c..5b8dd042545 100644 --- a/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts +++ b/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts @@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { Pressable } from 'react-native' import { afterEach, describe, expect, it, vi } from 'vitest' import { MobileNativeChatView } from './MobileNativeChatView' +import { MobileNativeChatAsk } from './MobileNativeChatAsk' import { useMobileNativeChatPromptPresentation } from './use-mobile-native-chat-prompt-presentation' import type { MobileChatPermission } from './mobile-native-chat-permission' vi.mock('react-native', async () => { @@ -134,6 +135,25 @@ afterEach(() => { vi.unstubAllGlobals() }) describe('terminal prompt presentation with the production view, card and composer', () => { + it('forwards terminal navigation without answering or cancelling the question', async () => { + const onAnswerInTerminal = vi.fn() + const onAnswerAsk = vi.fn(async () => true) + const onCancelAsk = vi.fn(async () => true) + await render({ + ask: { questions: [{ question: 'Pick', options: [{ label: 'A' }], multiSelect: false }] }, + askRequiresTerminal: true, + onAnswerInTerminal, + onAnswerAsk, + onCancelAsk + }) + const button = tree!.root.findByProps({ accessibilityLabel: 'Answer in terminal' }) + act(() => button.props.onPress()) + expect(onAnswerInTerminal).toHaveBeenCalledOnce() + expect(onAnswerAsk).not.toHaveBeenCalled() + expect(onCancelAsk).not.toHaveBeenCalled() + expect(tree!.root.findAllByType(MobileNativeChatAsk)).toHaveLength(0) + }) + it('accepted Deny hides exactly that occurrence and restores Send across reconnect', async () => { vi.stubGlobal('requestAnimationFrame', (callback: () => void) => setTimeout(callback, 0)) const onRespondPermission = vi.fn(async () => true) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 268b5c382d3..1e7d9cf5154 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -146,6 +146,8 @@ type Props = ComposerTrayProps & { /** Deliver the ask answer as per-question selections; the send hook turns them * into selector keystrokes (Claude) or pasted label text (other agents). */ onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise + askRequiresTerminal?: boolean + onAnswerInTerminal?: () => void onCancelAsk?: () => Promise /** Cancel a structured approval/question with exact item identity when supported. */ onCancelPrompt?: (prompt?: { itemId: string; expectedRevision: number }) => Promise @@ -213,6 +215,8 @@ export function MobileNativeChatView({ askKey, onDismissAsk, onAnswerAsk, + askRequiresTerminal = false, + onAnswerInTerminal, onCancelAsk, onCancelPrompt, onCollapseAsk, @@ -389,6 +393,8 @@ export function MobileNativeChatView({ diff --git a/mobile/src/session/MobileSessionActiveContent.tsx b/mobile/src/session/MobileSessionActiveContent.tsx index 412833202bd..1547add5e64 100644 --- a/mobile/src/session/MobileSessionActiveContent.tsx +++ b/mobile/src/session/MobileSessionActiveContent.tsx @@ -231,6 +231,7 @@ export function MobileSessionActiveContent({ ))} ({ + reportWorkerTerminalUserInput: vi.fn() +})) + +const prompt: AskPrompt = { + questions: [{ question: 'Pick', multiSelect: true, options: [{ label: 'A' }, { label: 'B' }] }] +} +let renderer: ReactTestRenderer | undefined +beforeEach(() => { + vi.useFakeTimers() + resetMobileNativeChatStaleInputForTests() + resetMobileNativeChatTerminalWritesForTests() +}) +afterEach(() => { + act(() => renderer?.unmount()) + vi.useRealTimers() +}) + +function mount(agent = 'omp') { + const sendRequest = vi.fn().mockResolvedValue({ + id: 'send', + ok: true, + result: { send: { accepted: true } } + }) + const client: RpcClient = { + sendRequest, + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => 1, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + const agentRef = { current: agent } + const onSendError = vi.fn() + const onAccepted = vi.fn() + let answer: ReturnType['answerAsk'] | undefined + function Probe(): null { + const sender = useMobileNativeChatAnswerSend({ + client, + enabled: true, + handleRef: { current: 'terminal' }, + deviceTokenRef: { current: 'device' }, + agentRef, + sessionId: 'session', + streamIdentity: 'host\0folder\0tab\0session', + onSendError + }) + answer = useNativeChatAcceptedAction(sender.answerAsk, onAccepted) + return null + } + act(() => { + renderer = create(createElement(Probe)) + }) + return { + sendRequest, + agentRef, + onSendError, + onAccepted, + answer: () => { + if (!answer) { + throw new Error('Answer hook did not mount') + } + return answer(prompt, [{ indices: [0, 1] }]) + } + } +} + +describe('OMP terminal-only question answers', () => { + it('refuses without healing drafts, writing input or dismissing the card', async () => { + const f = mount() + markMobileNativeChatInputStale('terminal') + await expect(f.answer()).resolves.toBe(false) + expect(f.sendRequest).not.toHaveBeenCalled() + expect(isMobileNativeChatInputStale('terminal')).toBe(true) + expect(f.onAccepted).not.toHaveBeenCalled() + expect(f.onSendError).toHaveBeenCalledWith('Answer this question in terminal view') + }) + + it('refuses a retained answer callback after the live agent becomes OMP', async () => { + const f = mount('claude') + f.agentRef.current = 'omp' + await expect(f.answer()).resolves.toBe(false) + expect(f.sendRequest).not.toHaveBeenCalled() + expect(f.onAccepted).not.toHaveBeenCalled() + }) + + it('stops paced writes before another group reaches a replacement OMP agent', async () => { + const f = mount('claude') + let sent: Promise | undefined + await act(async () => { + sent = f.answer() + }) + expect(f.sendRequest).toHaveBeenCalledTimes(1) + f.agentRef.current = 'omp' + await act(async () => { + await vi.runAllTimersAsync() + }) + await expect(sent).resolves.toBe(false) + expect(f.sendRequest).toHaveBeenCalledTimes(1) + expect(f.onAccepted).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.ts b/mobile/src/session/use-mobile-native-chat-answer-send.ts index b9b2ecdf0ec..79e43109d14 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.ts @@ -19,6 +19,7 @@ import { releaseMobileNativeChatTerminalWrite } from './mobile-native-chat-terminal-write-lock' import { + nativeChatAskRequiresTerminal, resolveNativeChatTranscriptAgent, shouldStepNativeChatAskAnswer } from '../../../src/shared/native-chat-agent-support' @@ -105,6 +106,11 @@ export function useMobileNativeChatAnswerSend(args: { const answerAsk = useCallback( async (prompt: AskPrompt, selections: AskAnswerSelection[]): Promise => { + if (nativeChatAskRequiresTerminal(agentRef.current)) { + cancelPending() + onSendError('Answer this question in terminal view') + return false + } const handle = handleRef.current if (!client || !handle || !enabled) { onSendError('Answer not sent (disconnected)') @@ -166,7 +172,8 @@ export function useMobileNativeChatAnswerSend(args: { activeRoute.client !== client || activeRoute.sessionId !== sessionId || activeRoute.streamIdentity !== streamIdentity || - handleRef.current !== handle + handleRef.current !== handle || + nativeChatAskRequiresTerminal(agentRef.current) ) { return false } diff --git a/mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts b/mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts index 0220e09c665..ecd2761b7a1 100644 --- a/mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts +++ b/mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts @@ -8,7 +8,7 @@ import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-as let renderer: ReactTestRenderer | undefined afterEach(() => act(() => renderer?.unmount())) -function fixture() { +function fixture(agent = 'claude') { const sendRequest = vi.fn().mockResolvedValue({ id: 'send', ok: true, @@ -27,6 +27,7 @@ function fixture() { } const cancelPending = vi.fn() const onSendError = vi.fn() + const agentRef = { current: agent } let cancelAsk: (() => Promise) | undefined function Probe(): null { cancelAsk = useMobileNativeChatCancelAsk({ @@ -34,6 +35,7 @@ function fixture() { enabled: true, handleRef: { current: 'terminal' }, deviceTokenRef: { current: 'device' }, + agentRef, cancelPending, onSendError }) @@ -46,6 +48,7 @@ function fixture() { sendRequest, cancelPending, onSendError, + agentRef, cancel: () => { if (!cancelAsk) { throw new Error('Cancel hook did not mount') @@ -56,6 +59,13 @@ function fixture() { } describe('mobile question rejection', () => { + it('refuses a retained cancel callback when the live agent becomes OMP', async () => { + const f = fixture() + f.agentRef.current = 'omp' + await expect(f.cancel()).resolves.toBe(false) + expect(f.sendRequest).not.toHaveBeenCalled() + expect(f.onSendError).toHaveBeenCalledWith('Answer this question in terminal view') + }) it('cancels pending answer writes then sends exactly one Escape without submitting', async () => { const f = fixture() await expect(f.cancel()).resolves.toBe(true) diff --git a/mobile/src/session/use-mobile-native-chat-cancel-ask.ts b/mobile/src/session/use-mobile-native-chat-cancel-ask.ts index fa949db146b..8b59f91a872 100644 --- a/mobile/src/session/use-mobile-native-chat-cancel-ask.ts +++ b/mobile/src/session/use-mobile-native-chat-cancel-ask.ts @@ -1,4 +1,5 @@ import { useCallback, type MutableRefObject } from 'react' +import { nativeChatAskRequiresTerminal } from '../../../src/shared/native-chat-agent-support' import type { RpcClient } from '../transport/rpc-client' import { sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send' @@ -10,12 +11,18 @@ export function useMobileNativeChatCancelAsk(args: { enabled: boolean handleRef: MutableRefObject deviceTokenRef: MutableRefObject + agentRef: MutableRefObject /** Cancels unsent answer groups before issuing Escape. */ cancelPending: () => void onSendError: (message: string) => void }): () => Promise { - const { client, enabled, handleRef, deviceTokenRef, cancelPending, onSendError } = args + const { client, enabled, handleRef, deviceTokenRef, agentRef, cancelPending, onSendError } = args return useCallback(async (): Promise => { + if (nativeChatAskRequiresTerminal(agentRef.current)) { + cancelPending() + onSendError('Answer this question in terminal view') + return false + } const handle = handleRef.current if (!client || !handle || !enabled) { onSendError('Cancel not sent (disconnected)') @@ -42,5 +49,5 @@ export function useMobileNativeChatCancelAsk(args: { onSendError('Cancel not sent') } return outcome === 'accepted' - }, [cancelPending, client, deviceTokenRef, enabled, handleRef, onSendError]) + }, [agentRef, cancelPending, client, deviceTokenRef, enabled, handleRef, onSendError]) } diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx index 6d374f1278b..be39835cd3a 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx @@ -76,6 +76,42 @@ const SEND: NativeChatInteractiveSend = { cancelAsk: mocks.cancelAsk } +describe('terminal-only question presentation', () => { + beforeEach(() => vi.clearAllMocks()) + afterEach(cleanup) + it.each([true, false])( + 'replaces answer and cancel controls with terminal navigation (%s)', + (available) => { + const onAnswerInTerminal = vi.fn() + const onDismiss = vi.fn() + render( + + ) + const button = screen.getByRole('button', { name: 'Answer in terminal' }) + expect(screen.getAllByRole('button')).toHaveLength(1) + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + expect(button).toHaveProperty('disabled', !available) + fireEvent.click(button) + expect(onAnswerInTerminal).toHaveBeenCalledTimes(available ? 1 : 0) + expect(mocks.sendAnswer).not.toHaveBeenCalled() + expect(mocks.cancelAsk).not.toHaveBeenCalled() + expect(mocks.sendRawVerified).not.toHaveBeenCalled() + expect(onDismiss).not.toHaveBeenCalled() + } + ) +}) + // Stands in for the view: it derives the shown card and renders it XOR the composer. function CardHarness({ canSend, diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx index c448ff6eb6d..940cb0b0e6a 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx @@ -3,6 +3,8 @@ import type { InteractivePromptCard } from './native-chat-interactive-prompt' import { NativeChatQuestionCard } from './NativeChatQuestionCard' import { NativeChatApprovalCard } from './NativeChatApprovalCard' import type { NativeChatInteractiveSend } from './use-native-chat-interactive-send' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' /** * Render one prompt occurrence the view chose (see useNativeChatPromptCardPresentation): a @@ -19,6 +21,8 @@ export function NativeChatInteractiveCard({ send, onDismiss, onCollapse, + requiresTerminal = false, + onAnswerInTerminal, shouldFocus = false, answerInputRef }: { @@ -28,6 +32,8 @@ export function NativeChatInteractiveCard({ onDismiss: () => void /** Fold this occurrence to a strip above the composer, writing nothing. */ onCollapse?: () => void + requiresTerminal?: boolean + onAnswerInTerminal?: () => void /** Take focus when the card takes the input region from the composer. */ shouldFocus?: boolean /** Forwarded to the question card's free-text row so pane-level Paste keeps @@ -61,6 +67,30 @@ export function NativeChatInteractiveCard({ }, [cancelPending]) if (card.kind === 'question') { + if (requiresTerminal) { + return ( +
+
+
+

+ {translate( + 'components.native-chat.question.terminalRequired', + 'Answer this OMP question in the terminal.' + )} +

+ +
+
+
+ ) + } return ( ({ vi.mock('./use-native-chat-interactive-prompt-card', () => ({ useNativeChatInteractivePromptCard: () => ({ kind: 'question', prompt }) })) -vi.mock('./NativeChatInteractiveCard', () => ({ - NativeChatInteractiveCard: (props: { send: NativeChatInteractiveSend }) => { - stubs.cardSend = props.send - return null +vi.mock('./NativeChatInteractiveCard', async (importOriginal) => { + const original = await importOriginal() + return { + NativeChatInteractiveCard: ( + props: React.ComponentProps + ) => { + stubs.cardSend = props.send + return props.requiresTerminal ? : null + } } -})) +}) vi.mock('./NativeChatComposer', () => ({ NativeChatComposer: (props: ComposerStubProps) => { stubs.composer = props @@ -73,6 +79,39 @@ afterEach(() => { }) describe('NativeChatResolvedView sends', () => { + it('opens the terminal for an OMP question instead of showing answer controls', () => { + stubs.session = { + messages: [], + status: 'ready', + sessionId: 'session-omp', + agent: 'omp', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' + } + const onSwitchToTerminal = vi.fn() + render( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Answer in terminal' })) + expect(onSwitchToTerminal).toHaveBeenCalledOnce() + expect(stubs.revealLatest).not.toHaveBeenCalled() + expect(screen.queryByRole('textbox')).toBeNull() + }) + it('brings the latest into view for a composer send and a prompt answer, not a Stop', () => { stubs.session = { messages: [ diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index 8d1c7a20a83..55fb7574c54 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -19,6 +19,7 @@ import { useNativeChatFind } from './use-native-chat-find' 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 { NativeChatPromptStrip } from './NativeChatPromptCollapse' @@ -114,9 +115,7 @@ export function NativeChatResolvedView({ ) // Why: Stop suppression must clear on a newer working epoch even when status // never leaves 'working' (interrupt + immediate next turn coalesced). - const hookWorkingEpoch = useAppStore( - (s) => s.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null - ) + const workingEpoch = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null) const canSend = useNativeChatCanSend(targetPtyId) // Reuse the verified composer send path for interactive cards and composer // stop (Stop sends ESC, the agent-TUI interrupt key). @@ -279,19 +278,19 @@ export function NativeChatResolvedView({ shouldClearNativeChatWorkingSuppression({ working: liveWorking, interrupted: workingInterrupted, - workingEpoch: hookWorkingEpoch, + workingEpoch, previousWorkingEpoch: previousWorkingEpochRef.current }) ) { setWorkingInterrupted(false) } - if (liveWorking && hookWorkingEpoch != null) { - previousWorkingEpochRef.current = hookWorkingEpoch + if (liveWorking && workingEpoch != null) { + previousWorkingEpochRef.current = workingEpoch } if (!liveWorking) { previousWorkingEpochRef.current = null } - }, [liveWorking, workingInterrupted, hookWorkingEpoch]) + }, [liveWorking, workingInterrupted, workingEpoch]) const { isWorking, turnActive, awaitingInput } = resolveNativeChatTerminalTurn({ isConversation, working: liveWorking, @@ -404,6 +403,8 @@ export function NativeChatResolvedView({ key={promptCardPresentation.occurrenceKey ?? 'prompt'} card={mountedPromptCard} send={interactiveSend} + requiresTerminal={nativeChatAskRequiresTerminal(agent)} + onAnswerInTerminal={onSwitchToTerminal} onDismiss={promptCardPresentation.dismiss} onCollapse={promptCardPresentation.collapse} shouldFocus={shownPromptCard !== null && isVisible && isFocusedGroup} diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx index 6266eeea500..753a3124184 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx @@ -64,6 +64,23 @@ describe('useNativeChatInteractiveSend', () => { mocks.sendNativeChatMessage.mockReturnValue(handle) }) + it('refuses OMP question answers and cancellation without writing or clearing the wait', async () => { + const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'omp')) + const settled = vi.fn() + act(() => { + expect(result.current.sendAnswer(PROMPT, [{ indices: [1] }], settled)).toEqual({ + settleAfterMs: 0 + }) + }) + expect(settled).toHaveBeenCalledWith(false) + await expect(result.current.cancelAsk()).resolves.toBe(false) + expect(mocks.sendNativeChatAskAnswer).not.toHaveBeenCalled() + expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled() + expect(mocks.sendRuntimePtyInput).not.toHaveBeenCalled() + expect(mocks.sendRuntimePtyInputVerified).not.toHaveBeenCalled() + expect(mocks.inferQuestionAnswered).not.toHaveBeenCalled() + }) + it('routes a non-selector answer through the pasted-text send path', () => { const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'grok')) diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts index 9ad2d4f53bb..deba72b75a7 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts @@ -4,6 +4,7 @@ import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-verified-input' import type { AgentType } from '../../../../shared/native-chat-types' import { + nativeChatAskRequiresTerminal, resolveNativeChatTranscriptAgent, shouldStepNativeChatAskAnswer } from '../../../../shared/native-chat-agent-support' @@ -98,6 +99,11 @@ export function useNativeChatInteractiveSend( selections: AskAnswerSelection[], onDeliverySettled?: (delivered: boolean) => void ): { settleAfterMs: number } => { + if (nativeChatAskRequiresTerminal(agent)) { + cancelInFlight() + onDeliverySettled?.(false) + return { settleAfterMs: 0 } + } if (!targetPtyId || !hasAskAnswer(prompt, selections)) { return { settleAfterMs: 0 } } @@ -160,8 +166,11 @@ export function useNativeChatInteractiveSend( const cancelAsk = useCallback(() => { cancelInFlight() + if (nativeChatAskRequiresTerminal(agent)) { + return Promise.resolve(false) + } return sendRawVerified(ESC) - }, [cancelInFlight, sendRawVerified]) + }, [agent, cancelInFlight, sendRawVerified]) const cancel = useCallback(() => { cancelInFlight() diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a447e67e853..5381871859c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19233,7 +19233,9 @@ "send": "Submit", "next": "Next", "skip": "Skip", - "sending": "Sending…" + "sending": "Sending…", + "terminalRequired": "Answer this OMP question in the terminal.", + "openTerminal": "Answer in terminal" }, "ask": { "awaiting": "Awaiting user input:", diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts index b0956a97a81..a3f46178674 100644 --- a/src/shared/native-chat-agent-support.ts +++ b/src/shared/native-chat-agent-support.ts @@ -37,6 +37,11 @@ export function shouldStepNativeChatAskAnswer(agent: string | null | undefined): ) } +/** OMP's configurable keys and existing checkbox state cannot be replaced through PTY input. */ +export function nativeChatAskRequiresTerminal(agent: string | null | undefined): boolean { + return resolveNativeChatTranscriptAgent(agent) === 'omp' +} + export function resolveNativeChatTranscriptAgent( agent: string | null | undefined ): NativeChatTranscriptAgent | null { diff --git a/src/shared/native-chat-ask.test.ts b/src/shared/native-chat-ask.test.ts index 0c9ff11753e..d08d63796f5 100644 --- a/src/shared/native-chat-ask.test.ts +++ b/src/shared/native-chat-ask.test.ts @@ -168,6 +168,17 @@ describe('extractPendingAsk', () => { }) describe('parseAskFromStatus', () => { + it('preserves OMP checkbox questions from live status and transcript history', () => { + const input = { + questions: [ + { id: 'q1', question: 'Pick', multi: true, options: [{ label: 'A' }, { label: 'B' }] } + ] + } + const prompt = parseAskFromStatus(JSON.stringify(input), 'ask') + expect(prompt?.questions[0]?.multiSelect).toBe(true) + expect(extractPendingAsk([message('m1', [call('ask', input)])])).toEqual(prompt) + }) + it('preserves OpenCode question envelope multiple selection on live status and replay', () => { const input = { id: 'question', diff --git a/src/shared/native-chat-ask.ts b/src/shared/native-chat-ask.ts index 44ee53c6c7d..450ad38ecf7 100644 --- a/src/shared/native-chat-ask.ts +++ b/src/shared/native-chat-ask.ts @@ -41,7 +41,8 @@ function parseCanonicalQuestionsInput(input: unknown): AskPrompt | null { multiSelect: 'multiSelect' in raw ? raw.multiSelect === true - : 'multiple' in raw && raw.multiple === true, + : ('multiple' in raw && raw.multiple === true) || + ('multi' in raw && raw.multi === true), options }) }