diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index b8f9cd9277b..f81a3605bf7 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -51,6 +51,8 @@ export function MobileNativeChatOverlay({ streamingText={controller.nativeChatStreamingText} onStop={controller.handleNativeChatStop} ask={controller.nativeChatAsk} + askKey={controller.nativeChatAskKey} + onDismissAsk={controller.dismissNativeChatAsk} onAnswerAsk={controller.handleNativeChatAnswerAsk} onCancelAsk={controller.handleNativeChatCancelAsk} question={controller.nativeChatQuestion} diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index f14efc1f25f..6ab88737665 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -20,7 +20,6 @@ import { mobileNativeChatEmptyState, type MobileNativeChatPendingItem } from './mobile-native-chat-render-data' -import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture' import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' @@ -85,6 +84,11 @@ type Props = { /** Structured AskUserQuestion prompt parsed from the transcript (preferred over * the heuristic question card). */ ask?: AskPrompt | null + /** Stable key for the ask card. Dismissal state lives in the controller (it + * must survive this subtree unmounting on a chat↔terminal toggle). */ + askKey?: string | null + /** Hide the answered/dismissed ask until a different question arrives. */ + onDismissAsk?: () => void /** 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 @@ -130,6 +134,8 @@ export function MobileNativeChatView({ filePaths, onNeedFiles, ask, + askKey, + onDismissAsk, onAnswerAsk, onCancelAsk, question, @@ -142,10 +148,6 @@ export function MobileNativeChatView({ const insets = useSafeAreaInsets() const listRef = useRef>(null) const [toolsExpanded, setToolsExpanded] = useState(false) - // Dismiss the question card as soon as it's answered; the live status lingers - // briefly (the agent emits a post-tool event with the same prompt), so hide it - // until a genuinely different question arrives. - const { askKey, showAsk, dismissAsk } = useMobileNativeChatAskDismiss(ask) // Lift the composer clear of the keyboard, plus the bottom safe-area so it // never sits under the home indicator / nav bar (mirrors the terminal dock). const bottomPad = keyboardInset > 0 ? keyboardInset + insets.bottom : insets.bottom @@ -333,22 +335,24 @@ export function MobileNativeChatView({ )} {/* Pending agent prompt: a structured AskUserQuestion wins, then a - heuristic permission, then a heuristic question. */} - {showAsk && ask ? ( + heuristic permission, then a heuristic question. The controller owns + dismissal (it must survive this subtree unmounting on a view toggle); + `ask` arrives already nulled while dismissed. */} + {ask ? ( { const accepted = (await onAnswerAsk?.(ask, selections)) ?? false if (accepted) { - dismissAsk() + onDismissAsk?.() } return accepted }} onCancel={async () => { const accepted = (await onCancelAsk?.()) ?? false if (accepted) { - dismissAsk() + onDismissAsk?.() } return accepted }} diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts index 8c81f0255c2..a4cf4f809eb 100644 --- a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts @@ -18,11 +18,47 @@ describe('useMobileNativeChatAskDismiss', () => { state = null }) - function Harness({ prompt }: { prompt: AskPrompt }): null { - state = useMobileNativeChatAskDismiss(prompt) + function Harness({ + prompt, + detectedPrompt = prompt, + scopeKey = 'tab-1', + observing = true + }: { + prompt: AskPrompt | null + detectedPrompt?: AskPrompt | null + scopeKey?: string | null + observing?: boolean + }): null { + state = useMobileNativeChatAskDismiss({ + ask: prompt, + detectedAsk: detectedPrompt, + scopeKey, + observing + }) return null } + async function mount(props: Parameters[0]): Promise { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, props)) + }) + } finally { + consoleSpy.mockRestore() + } + } + + async function update(props: Parameters[0]): Promise { + await act(async () => renderer?.update(createElement(Harness, props))) + } + const first: AskPrompt = { questions: [ { question: 'same first', multiSelect: false, options: [] }, @@ -37,24 +73,102 @@ describe('useMobileNativeChatAskDismiss', () => { } it('shows a structurally different replacement without an intervening null', async () => { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) - try { - await act(async () => { - renderer = create(createElement(Harness, { prompt: first })) - }) - } finally { - consoleSpy.mockRestore() - } + await mount({ prompt: first }) act(() => state?.dismissAsk()) expect(state?.showAsk).toBe(false) - await act(async () => renderer?.update(createElement(Harness, { prompt: replacement }))) + await update({ prompt: replacement }) + expect(state?.showAsk).toBe(true) + + await update({ prompt: first }) expect(state?.showAsk).toBe(true) }) + + it('keeps a dismissal while status gating hides a still-detected prompt', async () => { + await mount({ prompt: first }) + const acceptedDismiss = state!.dismissAsk + + await update({ prompt: null, detectedPrompt: first }) + act(() => acceptedDismiss()) + await update({ prompt: first }) + + expect(state?.showAsk).toBe(false) + }) + + it('ignores an answer that settles after its prompt cleared', async () => { + await mount({ prompt: first }) + const lateDismiss = state!.dismissAsk + + await update({ prompt: null }) + act(() => lateDismiss()) + await update({ prompt: first }) + + expect(state?.showAsk).toBe(true) + }) + + it('keeps a dismissal taken on-chat across a chat→terminal→chat toggle', async () => { + // The common case: answer the card, then toggle to the terminal. The prompt + // derives to null while hidden, and that null must not retire the dismissal. + await mount({ prompt: first }) + act(() => state?.dismissAsk()) + expect(state?.showAsk).toBe(false) + + await update({ prompt: null, observing: false }) + await update({ prompt: first, observing: true }) + + expect(state?.showAsk).toBe(false) + }) + + it('keeps a dismissal across a chat→terminal→chat toggle', async () => { + // While the chat surface is hidden the prompt derives to null; that null + // proves nothing about the agent and must not reset the dismissal. + await mount({ prompt: first }) + const acceptedDismiss = state!.dismissAsk + + await update({ prompt: null, observing: false }) + act(() => acceptedDismiss()) + await update({ prompt: first, observing: true }) + + expect(state?.showAsk).toBe(false) + }) + + it('forgets the dismissal once the prompt clears while observable', async () => { + await mount({ prompt: first }) + act(() => state?.dismissAsk()) + + // Agent moved on: the prompt cleared with chat visible. + await update({ prompt: null, observing: true }) + await update({ prompt: first, observing: true }) + + expect(state?.showAsk).toBe(true) + }) + + it('scopes the dismissal to the tab that showed the card', async () => { + await mount({ prompt: first, scopeKey: 'tab-1' }) + act(() => state?.dismissAsk()) + + // The same question on another tab is a different pending prompt. + await update({ prompt: first, scopeKey: 'tab-2' }) + expect(state?.showAsk).toBe(true) + act(() => state?.dismissAsk()) + expect(state?.showAsk).toBe(false) + + // Dismissing tab 2 must not overwrite tab 1's dismissal. + await update({ prompt: first, scopeKey: 'tab-1' }) + expect(state?.showAsk).toBe(false) + + await update({ prompt: first, scopeKey: 'tab-2' }) + expect(state?.showAsk).toBe(false) + }) + + it('records a settled answer against its originating tab', async () => { + await mount({ prompt: first, scopeKey: 'tab-1' }) + const tabOneDismiss = state!.dismissAsk + + await update({ prompt: replacement, scopeKey: 'tab-2' }) + act(() => tabOneDismiss()) + await update({ prompt: first, scopeKey: 'tab-1' }) + + expect(state?.showAsk).toBe(false) + }) }) diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts index 8f7289f0753..433eb9f62cf 100644 --- a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts @@ -1,26 +1,63 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { AskPrompt } from './mobile-native-chat-ask' /** Track the answered-ask key so the lingering live status doesn't re-show the * same card. The agent emits a post-tool event with the same prompt right after - * an answer, so the card is hidden until a genuinely different question arrives. */ -export function useMobileNativeChatAskDismiss(ask?: AskPrompt | null): { + * an answer, so the card is hidden until a genuinely different question arrives. + * + * Owned by the controller, not the chat subtree: the overlay unmounts on a + * chat↔terminal view toggle, and a dismissal must survive that round-trip. */ +export function useMobileNativeChatAskDismiss(args: { + ask?: AskPrompt | null + /** Ungated prompt payload. A working/done status hides the card but does not + * prove the sticky prompt itself cleared. */ + detectedAsk?: AskPrompt | null + /** Dismissals are scoped to the tab that showed the card, so one tab's + * dismissal can't hide an identical question on another tab. */ + scopeKey: string | null + /** True while the chat surface can actually observe the prompt. A null ask + * while hidden proves nothing (prompts aren't derived off-chat) and must not + * reset the dismissal — that reset is what resurfaced dismissed cards. */ + observing: boolean +}): { askKey: string | null showAsk: boolean dismissAsk: () => void } { + const { ask, detectedAsk = ask, scopeKey, observing } = args const askKey = ask ? JSON.stringify(ask.questions) : null - const [dismissedAskKey, setDismissedAskKey] = useState(null) - // Once the prompt clears (agent moved on), forget the dismissal so a later - // question — even an identical one — shows again instead of staying hidden. - const askPresent = ask != null + const detectedAskKey = detectedAsk ? JSON.stringify(detectedAsk.questions) : null + const detectedAskKeysRef = useRef(new Map()) + const [dismissedByScope, setDismissedByScope] = useState>( + () => new Map() + ) useEffect(() => { - if (!askPresent) { - setDismissedAskKey(null) + if (observing) { + detectedAskKeysRef.current.set(scopeKey, detectedAskKey) } - }, [askPresent]) - const showAsk = askPresent && askKey !== dismissedAskKey - const dismissAsk = (): void => setDismissedAskKey(askKey) + }, [observing, detectedAskKey, scopeKey]) + // A cleared or genuinely different detected prompt retires the old dismissal. + useEffect(() => { + if (observing) { + setDismissedByScope((previous) => { + const dismissedKey = previous.get(scopeKey) + if (dismissedKey === undefined || dismissedKey === detectedAskKey) { + return previous + } + const next = new Map(previous) + next.delete(scopeKey) + return next + }) + } + }, [observing, detectedAskKey, scopeKey]) + const showAsk = askKey !== null && dismissedByScope.get(scopeKey) !== askKey + const dismissAsk = (): void => { + // An answer may settle after the prompt cleared or was replaced. Only the + // still-current prompt may install a lasting dismissal. + if (askKey !== null && detectedAskKeysRef.current.get(scopeKey) === askKey) { + setDismissedByScope((previous) => new Map(previous).set(scopeKey, askKey)) + } + } return { askKey, showAsk, dismissAsk } } diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index 2b4fb0e9d0f..2205397374f 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -45,7 +45,12 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({ } })) vi.mock('./use-mobile-native-chat-prompts', () => ({ - useMobileNativeChatPrompts: () => ({ permission: null, question: null, ask: null }) + useMobileNativeChatPrompts: () => ({ + permission: null, + question: null, + detectedAsk: null, + ask: null + }) })) vi.mock('./use-mobile-native-chat-answer-send', () => ({ useMobileNativeChatAnswerSend: () => ({ answerAsk: vi.fn(), cancelPending: vi.fn() }) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index fd0a564b19c..16573bd87fd 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -20,6 +20,7 @@ import { openMobileNativeChatFile } from './mobile-native-chat-open-file' import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' +import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask' import { useMobileNativeChatDrafts, @@ -51,7 +52,13 @@ export type MobileNativeChatController = { nativeChatStreamingText?: string nativeChatPermission: ReturnType nativeChatQuestion: ReturnType + /** The pending ask, already null while dismissed (dismissal lives here so it + * survives the chat-view subtree unmounting on a view toggle). */ nativeChatAsk: ReturnType + /** Stable key for the current ask card (keys the card component). */ + nativeChatAskKey: string | null + /** Hide the current ask until a genuinely different question arrives. */ + dismissNativeChatAsk: () => void handleNativeChatOpenFile: (relativePath: string) => void handleNativeChatAnswerAsk: ( prompt: AskPrompt, @@ -169,12 +176,23 @@ export function useMobileNativeChatController(args: { const { permission: nativeChatPermission, question: nativeChatQuestion, - ask: nativeChatAsk + detectedAsk: nativeChatDetectedAsk, + ask: nativeChatAskPrompt } = useMobileNativeChatPrompts({ enabled: activeChatResolution != null, status: nativeChatStatus, messages: nativeChatSession.messages }) + const { + askKey: nativeChatAskKey, + showAsk: showNativeChatAsk, + dismissAsk: dismissNativeChatAsk + } = useMobileNativeChatAskDismiss({ + ask: nativeChatAskPrompt, + detectedAsk: nativeChatDetectedAsk, + scopeKey: activeSessionTabId, + observing: showNativeChat + }) const handleNativeChatOpenFile = useCallback( (pathText: string) => { @@ -275,7 +293,9 @@ export function useMobileNativeChatController(args: { nativeChatStreamingText, nativeChatPermission, nativeChatQuestion, - nativeChatAsk, + nativeChatAsk: showNativeChatAsk ? nativeChatAskPrompt : null, + nativeChatAskKey, + dismissNativeChatAsk, handleNativeChatOpenFile, handleNativeChatAnswerAsk: answerAsk, handleNativeChatCancelAsk: cancelAsk, diff --git a/mobile/src/session/use-mobile-native-chat-prompts.test.ts b/mobile/src/session/use-mobile-native-chat-prompts.test.ts index 413fd1047fd..e31715fc737 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.test.ts @@ -2,26 +2,38 @@ import { createElement } from 'react' import TestRenderer from 'react-test-renderer' import { describe, expect, it } from 'vitest' import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts' const APPROVAL = JSON.stringify({ approval: { tool: 'Bash', summary: 'pnpm build > build.log 2>&1' } }) -function permissionFor(status: Partial | null): unknown { - let captured: unknown +const ASK = JSON.stringify({ + questions: [{ question: 'Which path?', options: ['fast', 'safe'] }] +}) + +function promptsFor( + status: Partial | null, + messages: NativeChatMessage[] = [] +): ReturnType { + let captured: ReturnType | undefined function Probe(): null { captured = useMobileNativeChatPrompts({ enabled: true, status: status as AgentStatusEntry | null, - messages: [] - }).permission + messages + }) return null } TestRenderer.act(() => { TestRenderer.create(createElement(Probe)) }) - return captured + return captured! +} + +function permissionFor(status: Partial | null): unknown { + return promptsFor(status).permission } describe('useMobileNativeChatPrompts approval-envelope state gate', () => { @@ -60,3 +72,45 @@ describe('useMobileNativeChatPrompts approval-envelope state gate', () => { expect(permission?.options.map((o) => o.label)).toEqual(['Yes', 'No']) }) }) + +describe('useMobileNativeChatPrompts ask state gate', () => { + const askMessages: NativeChatMessage[] = [ + { + id: 'm1', + role: 'assistant', + blocks: [ + { + type: 'tool-call', + name: 'AskUserQuestion', + input: { questions: [{ question: 'Which path?', options: ['fast', 'safe'] }] } + } + ], + timestamp: 0, + source: 'transcript' + } + ] + + it('renders the ask card only while the agent is waiting or blocked', () => { + expect(promptsFor({ state: 'waiting', interactivePrompt: ASK }).ask).toMatchObject({ + questions: [{ question: 'Which path?' }] + }) + expect(promptsFor({ state: 'blocked', interactivePrompt: ASK }).ask).not.toBeNull() + }) + + it('renders no ask card from a sticky prompt while the agent is working or done', () => { + // The prompt payload outlives its answer — same paused gate as permission. + const working = promptsFor({ state: 'working', interactivePrompt: ASK }) + expect(working.ask).toBeNull() + expect(working.detectedAsk).not.toBeNull() + + const done = promptsFor({ state: 'done', interactivePrompt: ASK }) + expect(done.ask).toBeNull() + expect(done.detectedAsk).not.toBeNull() + }) + + it('gates the transcript-derived pending ask the same way', () => { + expect(promptsFor({ state: 'waiting' }, askMessages).ask).not.toBeNull() + expect(promptsFor({ state: 'working' }, askMessages).ask).toBeNull() + expect(promptsFor(null, askMessages).ask).toBeNull() + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-prompts.ts b/mobile/src/session/use-mobile-native-chat-prompts.ts index 1e362030750..26e98e96ee1 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.ts @@ -8,6 +8,7 @@ import { parseAgentQuestion } from './mobile-native-chat-question' export type MobileNativeChatPrompts = { permission: ReturnType question: ReturnType + detectedAsk: ReturnType ask: ReturnType } @@ -42,9 +43,15 @@ export function useMobileNativeChatPrompts(args: { [askFromStatus, messages] ) + const detectedAsk = askFromStatus ?? askFromMessages + return { permission, question, - ask: enabled ? (askFromStatus ?? askFromMessages) : null + detectedAsk: enabled ? detectedAsk : null, + // Ask cards sit inside the same paused gate as the approval envelope above: + // the prompt payload outlives its answer, so only a waiting/blocked agent + // may surface one — never a working or done one. + ask: enabled && blocked ? detectedAsk : null } }