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 <jairusbpace@gmail.com>
Co-authored-by: Daniel Lopez <dafeloru@gmail.com>
This commit is contained in:
Neil
2026-10-21 13:04:38 -07:00
committed by GitHub
co-authored by jairuspace Daniel Lopez
parent 67886127dc
commit b128cbbd6a
22 changed files with 511 additions and 60 deletions
@@ -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<typeof createElement> {
@@ -43,7 +47,9 @@ function overlayElement(tick: Tick): ReturnType<typeof createElement> {
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<typeof createElement> {
} 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 }))
@@ -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}
@@ -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) =>
@@ -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<boolean>
requiresTerminal?: boolean
onAnswerInTerminal?: () => void
onCancelAsk?: () => Promise<boolean>
onCancelPrompt?: (prompt?: NonNullable<MobileChatPermission['prompt']>) => Promise<boolean>
onCollapseAsk?: () => void
@@ -42,43 +47,58 @@ export function MobileNativeChatPromptCard({
question?: MobileChatQuestion | null
onAnswerQuestion?: (text: string) => Promise<boolean>
}): React.JSX.Element | null {
const card = ask ? (
<MobileNativeChatAsk
key={askKey ?? 'ask'}
prompt={ask}
onAnswer={async (selections) => {
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 ? (
<MobileNativeChatPermission
key={JSON.stringify(permission)}
permission={permission}
onRespond={async (send) => (await onRespondPermission?.(send)) ?? false}
onCancel={onCancelPrompt}
onCollapse={onCollapsePrompt}
/>
) : question ? (
<MobileNativeChatQuestion
key={mobileChatQuestionKey(question)}
question={question}
onAnswer={async (text) => (await onAnswerQuestion?.(text)) ?? false}
onCancel={onCancelPrompt}
onCollapse={onCollapsePrompt}
/>
) : null
const card =
ask && requiresTerminal ? (
<View style={styles.terminalCard}>
<Text style={styles.explanation}>Answer this OMP question in the terminal.</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel="Answer in terminal"
accessibilityState={{ disabled: !onAnswerInTerminal }}
disabled={!onAnswerInTerminal}
onPress={onAnswerInTerminal}
style={styles.terminalButton}
>
<Text style={styles.terminalButtonText}>Answer in terminal</Text>
</Pressable>
</View>
) : ask ? (
<MobileNativeChatAsk
key={askKey ?? 'ask'}
prompt={ask}
onAnswer={async (selections) => {
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 ? (
<MobileNativeChatPermission
key={JSON.stringify(permission)}
permission={permission}
onRespond={async (send) => (await onRespondPermission?.(send)) ?? false}
onCancel={onCancelPrompt}
onCollapse={onCollapsePrompt}
/>
) : question ? (
<MobileNativeChatQuestion
key={mobileChatQuestionKey(question)}
question={question}
onAnswer={async (text) => (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'
}
})
@@ -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)
@@ -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<boolean>
askRequiresTerminal?: boolean
onAnswerInTerminal?: () => void
onCancelAsk?: () => Promise<boolean>
/** Cancel a structured approval/question with exact item identity when supported. */
onCancelPrompt?: (prompt?: { itemId: string; expectedRevision: number }) => Promise<boolean>
@@ -213,6 +215,8 @@ export function MobileNativeChatView({
askKey,
onDismissAsk,
onAnswerAsk,
askRequiresTerminal = false,
onAnswerInTerminal,
onCancelAsk,
onCancelPrompt,
onCollapseAsk,
@@ -389,6 +393,8 @@ export function MobileNativeChatView({
<MobileNativeChatPromptCard
key={promptKey ?? undefined}
{...{ ask, askKey, onDismissAsk, onAnswerAsk, onCancelAsk, onCancelPrompt, onCollapseAsk }}
requiresTerminal={askRequiresTerminal}
onAnswerInTerminal={onAnswerInTerminal}
{...{ permission, onRespondPermission, question, onAnswerQuestion, onCollapsePrompt }}
collapsedPrompt={collapsedPrompt}
/>
@@ -231,6 +231,7 @@ export function MobileSessionActiveContent({
))}
<MobileNativeChatOverlay
controller={nativeChatController}
activeSessionTabId={controller.activeSessionTab?.id}
onOpenFile={handleNativeChatFileTap}
images={nativeChatImages}
structuredComposer={nativeChatStructuredComposer}
@@ -42,6 +42,7 @@ export function useMobileBridgeChatPromptWrites(args: {
enabled,
handleRef,
deviceTokenRef,
agentRef: args.agentRef,
cancelPending,
onSendError
})
@@ -0,0 +1,119 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AskPrompt } from '../../../src/shared/native-chat-ask'
import type { RpcClient } from '../transport/rpc-client'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes'
import {
isMobileNativeChatInputStale,
markMobileNativeChatInputStale,
resetMobileNativeChatStaleInputForTests
} from './mobile-native-chat-stale-input'
import { resetMobileNativeChatTerminalWritesForTests } from './mobile-native-chat-terminal-write-lock'
vi.mock('../terminal/worker-terminal-takeover-report', () => ({
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<RpcClient['sendRequest']>().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<typeof useMobileNativeChatAnswerSend>['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<boolean> | 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()
})
})
@@ -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<boolean> => {
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
}
@@ -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<RpcClient['sendRequest']>().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<boolean>) | 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)
@@ -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<string | null>
deviceTokenRef: MutableRefObject<string | null>
agentRef: MutableRefObject<string | null>
/** Cancels unsent answer groups before issuing Escape. */
cancelPending: () => void
onSendError: (message: string) => void
}): () => Promise<boolean> {
const { client, enabled, handleRef, deviceTokenRef, cancelPending, onSendError } = args
const { client, enabled, handleRef, deviceTokenRef, agentRef, cancelPending, onSendError } = args
return useCallback(async (): Promise<boolean> => {
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])
}
@@ -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(
<NativeChatInteractiveCard
card={{
kind: 'question',
prompt: {
questions: [{ question: 'Pick', options: [{ label: 'A' }], multiSelect: false }]
}
}}
send={SEND}
onDismiss={onDismiss}
requiresTerminal
onAnswerInTerminal={available ? onAnswerInTerminal : undefined}
/>
)
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,
@@ -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 (
<div className="shrink-0 bg-chat-canvas">
<div className="mx-auto w-full max-w-(--chat-content-max-width) px-3 pt-2 pb-4 sm:px-4">
<div className="rounded-lg border border-input bg-card p-3 shadow-xs">
<p className="text-sm text-foreground">
{translate(
'components.native-chat.question.terminalRequired',
'Answer this OMP question in the terminal.'
)}
</p>
<Button
className="mt-3"
size="sm"
disabled={!onAnswerInTerminal}
onClick={onAnswerInTerminal}
>
{translate('components.native-chat.question.openTerminal', 'Answer in terminal')}
</Button>
</div>
</div>
</div>
)
}
return (
<NativeChatQuestionCard
prompt={card.prompt}
@@ -1,12 +1,13 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { useImperativeHandle } from 'react'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
import type { AskPrompt } from './native-chat-interactive-prompt'
import type { NativeChatInteractiveSend } from './use-native-chat-interactive-send'
import type { NativeChatMessageListHandle } from './use-native-chat-reveal-latest'
import type * as InteractiveCardModule from './NativeChatInteractiveCard'
// The transcript and composer are stubbed: the wire under test is which of this
// pane's sends tell the transcript to bring the latest into view.
@@ -46,12 +47,17 @@ vi.mock('./use-native-chat-interactive-send', () => ({
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<typeof InteractiveCardModule>()
return {
NativeChatInteractiveCard: (
props: React.ComponentProps<typeof original.NativeChatInteractiveCard>
) => {
stubs.cardSend = props.send
return props.requiresTerminal ? <original.NativeChatInteractiveCard {...props} /> : 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(
<NativeChatResolvedView
paneKey="tab-omp:leaf-omp"
agent="omp"
sessionId="session-omp"
transcriptPath={null}
isVisible
isFocusedGroup={false}
targetPtyId="pty-omp"
terminalTabId="tab-omp"
ownsTabWideLaunchDraft={false}
onSwitchToTerminal={onSwitchToTerminal}
/>
)
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: [
@@ -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}
@@ -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'))
@@ -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()
+3 -1
View File
@@ -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:",
+5
View File
@@ -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 {
+11
View File
@@ -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',
+2 -1
View File
@@ -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
})
}