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.
This commit is contained in:
Brennan Benson
2026-08-04 04:06:49 -07:00
parent 9aef3b2d1a
commit 6b9d5e18b8
3 changed files with 43 additions and 6 deletions
@@ -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
@@ -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
@@ -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
})