fix(mobile): keep native chat ask dismissals tab-scoped and gated

Dismissal state lived in the chat view subtree, which unmounts on a
chat<->terminal toggle, so an answered ask card came back on return. It
also had no tab scope and no waiting/blocked gate.

- move dismissal into the controller, keyed per session tab
- gate ask cards on waiting/blocked like the permission path already is,
  and retire a dismissal off the ungated detected prompt so a working/done
  status can't be mistaken for the prompt clearing
- ignore a dismissal that settles after its prompt cleared or was replaced

Refs STA-3333.
This commit is contained in:
Brennan Benson
2026-08-04 01:56:58 -07:00
parent e9cf106769
commit 086bedecec
8 changed files with 290 additions and 47 deletions
@@ -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}
+13 -9
View File
@@ -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<boolean>
@@ -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<FlatList<NativeChatMessage>>(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({
</GestureHandlerRootView>
)}
{/* 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 ? (
<MobileNativeChatAsk
key={askKey ?? 'ask'}
prompt={ask}
onAnswer={async (selections) => {
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
}}
@@ -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<typeof Harness>[0]): Promise<void> {
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<typeof Harness>[0]): Promise<void> {
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)
})
})
@@ -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<string | null>(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<string | null, string | null>())
const [dismissedByScope, setDismissedByScope] = useState<Map<string | null, string>>(
() => 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 }
}
@@ -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() })
@@ -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<typeof detectAgentPermission>
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
/** The pending ask, already null while dismissed (dismissal lives here so it
* survives the chat-view subtree unmounting on a view toggle). */
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
/** 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,
@@ -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<AgentStatusEntry> | null): unknown {
let captured: unknown
const ASK = JSON.stringify({
questions: [{ question: 'Which path?', options: ['fast', 'safe'] }]
})
function promptsFor(
status: Partial<AgentStatusEntry> | null,
messages: NativeChatMessage[] = []
): ReturnType<typeof useMobileNativeChatPrompts> {
let captured: ReturnType<typeof useMobileNativeChatPrompts> | 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<AgentStatusEntry> | 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()
})
})
@@ -8,6 +8,7 @@ import { parseAgentQuestion } from './mobile-native-chat-question'
export type MobileNativeChatPrompts = {
permission: ReturnType<typeof detectAgentPermission>
question: ReturnType<typeof parseAgentQuestion>
detectedAsk: ReturnType<typeof parseAskFromStatus>
ask: ReturnType<typeof parseAskFromStatus>
}
@@ -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
}
}