From ea3df05ecf102d2fb7e795996d6c6bf9bf1fa524 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:17:14 -0700 Subject: [PATCH] fix(mobile): keep an ask dismissal through the transcript re-subscribe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../use-mobile-native-chat-ask-dismiss.ts | 6 +- .../use-mobile-native-chat-controller.test.ts | 124 +++++++++++++++++- .../use-mobile-native-chat-controller.ts | 9 +- 3 files changed, 129 insertions(+), 10 deletions(-) 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 433eb9f62cf..fd410013ba3 100644 --- a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts @@ -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 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 2205397374f..33b013f1c33 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -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[] = [] +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() + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 16573bd87fd..e83b0e0869d 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -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(