fix(mobile): keep an ask dismissal through the transcript re-subscribe

A view toggle or tab switch re-subscribes the native-chat transcript, and
useMobileNativeChatSession withholds `messages` until that read settles. A
transcript-derived ask therefore reads as null while the chat surface is
already visible, so the reset effect took it as "the agent moved on" and
retired a live dismissal — the answered card came back, which is the bug
the off-chat guard was meant to close.

Treat an unobserved null as unobserved: `observing` now also requires the
read to have settled. A prompt that is already detected stays observable on
its own, so a status-derived ask still registers on first paint and an
answer taken during that first load is still accepted.
This commit is contained in:
Brennan Benson
2026-08-04 02:17:14 -07:00
parent 086bedecec
commit ea3df05ecf
3 changed files with 129 additions and 10 deletions
@@ -15,9 +15,9 @@ export function useMobileNativeChatAskDismiss(args: {
/** 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. */
/** True while the chat surface can actually observe the prompt. A null ask it
* cannot see — off-chat, or before a re-subscribed transcript lands — proves
* nothing and must not reset the dismissal; that reset resurfaced the card. */
observing: boolean
}): {
askKey: string | null
@@ -15,6 +15,12 @@ const holdUnconfirmedSend = vi.fn()
const viewMode = { isTabChatView: (_tabId: string) => true }
const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false }
const draftsArgs: Record<string, unknown>[] = []
const promptsState = {
permission: null as unknown,
question: null as unknown,
detectedAsk: null as unknown,
ask: null as unknown
}
// The controller composes many session hooks; each is mocked to a minimal shape
// so this test isolates the send seam (outcome -> drafts accounting).
@@ -45,12 +51,7 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({
}
}))
vi.mock('./use-mobile-native-chat-prompts', () => ({
useMobileNativeChatPrompts: () => ({
permission: null,
question: null,
detectedAsk: null,
ask: null
})
useMobileNativeChatPrompts: () => promptsState
}))
vi.mock('./use-mobile-native-chat-answer-send', () => ({
useMobileNativeChatAnswerSend: () => ({ answerAsk: vi.fn(), cancelPending: vi.fn() })
@@ -426,3 +427,114 @@ describe('useMobileNativeChatController launch-draft wiring', () => {
expect(draftsArgs.at(-1)).toMatchObject({ launchDraft: null, chatActive: true })
})
})
describe('useMobileNativeChatController ask dismissal across a transcript reload', () => {
let renderer: ReactTestRenderer | null = null
let controller: MobileNativeChatController | null = null
const clientStub = { sendRequest: vi.fn() }
const PROMPT = { questions: [{ question: 'Which path?', multiSelect: false, options: [] }] }
const chatTab = { type: 'terminal', id: 'tab-1', launchAgent: 'claude' }
function Harness(): null {
controller = useMobileNativeChatController({
client: clientStub as unknown as RpcClient,
connState: 'connected',
hostId: 'h',
worktreeId: 'w',
activeSessionTab: chatTab as never,
activeSessionTabId: 'tab-1',
activeHandleRef: { current: 'term-1' },
deviceTokenRef: { current: null },
nativeChatTranscriptIsLocalReadable: true,
nativeChatInputLeaseReady: true,
onSendError: vi.fn(),
onSendResolved: vi.fn()
})
return null
}
/** Re-render under the current viewMode/prompts/session stand-ins. */
function step(): void {
act(() => {
renderer?.update(createElement(Harness))
})
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
viewMode.isTabChatView = () => true
sessionState.transcriptLoading = false
promptsState.ask = PROMPT
promptsState.detectedAsk = PROMPT
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
return
}
original(...a)
})
try {
act(() => {
renderer = create(createElement(Harness))
})
} finally {
spy.mockRestore()
}
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
controller = null
promptsState.ask = null
promptsState.detectedAsk = null
sessionState.transcriptLoading = false
viewMode.isTabChatView = () => true
})
it('keeps the dismissal while the re-subscribed transcript is still empty', () => {
// Toggling views re-subscribes the transcript, so a transcript-derived ask
// reads as null for a beat with chat already visible. Believing that null
// retires the dismissal and the answered card comes back (#12497).
expect(controller?.nativeChatAsk).not.toBeNull()
act(() => controller?.dismissNativeChatAsk())
expect(controller?.nativeChatAsk).toBeNull()
// Chat -> terminal.
viewMode.isTabChatView = () => false
promptsState.ask = null
promptsState.detectedAsk = null
step()
// Terminal -> chat: observable again, but the transcript read is in flight.
viewMode.isTabChatView = () => true
sessionState.transcriptLoading = true
step()
// The read lands and re-derives the same still-pending ask.
sessionState.transcriptLoading = false
promptsState.ask = PROMPT
promptsState.detectedAsk = PROMPT
step()
expect(controller?.nativeChatAsk).toBeNull()
})
it('still retires the dismissal once a settled transcript reports no prompt', () => {
// The load-window guard must not swallow the genuine reset: a prompt that
// clears with the read settled means the agent moved on.
act(() => controller?.dismissNativeChatAsk())
expect(controller?.nativeChatAsk).toBeNull()
promptsState.ask = null
promptsState.detectedAsk = null
step()
promptsState.ask = PROMPT
promptsState.detectedAsk = PROMPT
step()
expect(controller?.nativeChatAsk).not.toBeNull()
})
})
@@ -183,6 +183,13 @@ export function useMobileNativeChatController(args: {
status: nativeChatStatus,
messages: nativeChatSession.messages
})
// A view toggle or tab switch re-subscribes the transcript, so `messages` is
// empty until the read lands and the transcript-derived prompt reads as null.
// Believing that null retires a live dismissal — the same resurfacing bug the
// off-chat guard fixes. A detected prompt is observable on its own; a null one
// only counts once the read has settled.
const nativeChatAskObservable =
showNativeChat && (nativeChatDetectedAsk != null || !nativeChatSession.transcriptLoading)
const {
askKey: nativeChatAskKey,
showAsk: showNativeChatAsk,
@@ -191,7 +198,7 @@ export function useMobileNativeChatController(args: {
ask: nativeChatAskPrompt,
detectedAsk: nativeChatDetectedAsk,
scopeKey: activeSessionTabId,
observing: showNativeChat
observing: nativeChatAskObservable
})
const handleNativeChatOpenFile = useCallback(