mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
co-authored by
jairuspace
Daniel Lopez
parent
67886127dc
commit
b128cbbd6a
@@ -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}
|
||||
|
||||
+45
-6
@@ -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()
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user