From 6b9d5e18b886adfcac88a24106b6f7d975c3cd88 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:06:49 -0700 Subject: [PATCH] fix(mobile): scope the ask dismissal to the provider session, not the tab A restart, /clear, or resume swaps the provider session inside one tab. The next session's first question is often byte-identical, so a tab-keyed dismissal hid the live card and left the turn blocked with nothing to act on. --- .../use-mobile-native-chat-ask-dismiss.ts | 5 +-- .../use-mobile-native-chat-controller.test.ts | 35 +++++++++++++++++-- .../use-mobile-native-chat-controller.ts | 9 ++++- 3 files changed, 43 insertions(+), 6 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 4bbf4abbec0..458474e7915 100644 --- a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts @@ -14,8 +14,9 @@ export function useMobileNativeChatAskDismiss(args: { * `ask`: reading the gated prompt as the detected one is the resurfacing bug * this hook exists to close. */ 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. */ + /** Dismissals are scoped to the tab *and* provider session that showed the + * card, so neither another tab nor a restarted session in the same tab can + * have an identical question hidden by an answer that was never about it. */ scopeKey: string | null /** 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 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 11b57f86417..f567705509a 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -436,8 +436,9 @@ describe('useMobileNativeChatController ask dismissal across a transcript reload const PROMPT = { questions: [{ question: 'Which path?', multiSelect: false, options: [] }] } const chatTab = { type: 'terminal', id: 'tab-1', launchAgent: 'claude' } - /** Mutable so a test can move the user to another tab mid-render. */ - const activeTab = { id: 'tab-1' } + /** Mutable so a test can move the user to another tab — or restart the agent + * into a new provider session on the same tab — mid-render. */ + const activeTab = { id: 'tab-1', sessionId: 'session-1' } function Harness(): null { controller = useMobileNativeChatController({ @@ -445,7 +446,11 @@ describe('useMobileNativeChatController ask dismissal across a transcript reload connState: 'connected', hostId: 'h', worktreeId: 'w', - activeSessionTab: { ...chatTab, id: activeTab.id } as never, + activeSessionTab: { + ...chatTab, + id: activeTab.id, + agentStatus: { agentType: 'claude', providerSession: { id: activeTab.sessionId } } + } as never, activeSessionTabId: activeTab.id, activeHandleRef: { current: 'term-1' }, deviceTokenRef: { current: null }, @@ -507,6 +512,7 @@ describe('useMobileNativeChatController ask dismissal across a transcript reload setTranscript('ready', 0) viewMode.isTabChatView = () => true activeTab.id = 'tab-1' + activeTab.sessionId = 'session-1' }) it('scopes the dismissal to the tab it was taken on', () => { @@ -521,6 +527,29 @@ describe('useMobileNativeChatController ask dismissal across a transcript reload expect(controller?.nativeChatAsk).not.toBeNull() }) + it('shows a restarted session’s identical first question on the same tab', () => { + // A restart, `/clear`, or resume swaps the provider session inside one tab, + // and the next session's first question is often byte-identical — same repo, + // same prompt, same template. Keyed by tab alone the old answer hides it, so + // the live card never renders and the turn sits blocked with nothing to act on. + act(() => controller?.dismissNativeChatAsk()) + expect(controller?.nativeChatAsk).toBeNull() + + // The restart re-subscribes the transcript, so the read is in flight for a beat. + activeTab.sessionId = 'session-2' + setTranscript('loading') + promptsState.ask = null + promptsState.detectedAsk = null + step() + + setTranscript('ready') + promptsState.ask = PROMPT + promptsState.detectedAsk = PROMPT + step() + + expect(controller?.nativeChatAsk).not.toBeNull() + }) + it('retires the dismissal only on the ungated prompt, not the gated one', () => { // The paused gate hides the card whenever the agent is not waiting, but a // hidden card is no evidence the sticky status prompt cleared. Feeding the diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 8e51d9d13e5..a62224d351b 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -206,6 +206,13 @@ export function useMobileNativeChatController(args: { (nativeChatSession.status === 'error' && nativeChatSession.messages.length > 0) const nativeChatAskObservable = showNativeChat && (nativeChatDetectedAsk != null || nativeChatTranscriptSettled) + // Scope the dismissal to the transcript the card was answered on, not the tab + // alone: a restart, `/clear`, or resume swaps the provider session inside one + // tab, and the new session's first question is often byte-identical (same repo, + // same prompt, same template). Keyed by tab, the old answer read as "already + // dismissed" and the live card never rendered. Same `\0` shape as + // `streamIdentity` above, which is the identity the transcript itself tracks. + const nativeChatAskScopeKey = `${activeSessionTabId ?? ''}\0${activeChatSessionId ?? ''}` const { askKey: nativeChatAskKey, showAsk: showNativeChatAsk, @@ -213,7 +220,7 @@ export function useMobileNativeChatController(args: { } = useMobileNativeChatAskDismiss({ ask: nativeChatAskPrompt, detectedAsk: nativeChatDetectedAsk, - scopeKey: activeSessionTabId, + scopeKey: nativeChatAskScopeKey, observing: nativeChatAskObservable })