From dd803c0416e28a6ccbb783cafe794e4bde50e762 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:19:41 -0700 Subject: [PATCH] fix(mobile): don't derive a tappable ask card from the held transcript The cache this PR adds keeps the previous list rendered while a swapped client re-reads. useMobileNativeChatPrompts was the one consumer reading `messages` without honouring `transcriptLoading`, so an ask answered on the terminal resurrected as a live, tappable card during that window. Gating on `transcriptLoading` is exactly base behaviour: `setRead` only ever stores 'ready'/'error', so status==='loading' implied an empty list before this PR. The live `askFromStatus` path is untouched. --- .../use-mobile-native-chat-controller.ts | 3 +- .../use-mobile-native-chat-prompts.test.ts | 67 ++++++++++++++++++- .../session/use-mobile-native-chat-prompts.ts | 12 +++- 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index c6cbc0c73ff..a8aec6d546a 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -175,7 +175,8 @@ export function useMobileNativeChatController(args: { } = useMobileNativeChatPrompts({ enabled: activeChatResolution != null, status: nativeChatStatus, - messages: nativeChatSession.messages + messages: nativeChatSession.messages, + transcriptLoading: nativeChatSession.transcriptLoading }) const handleNativeChatOpenFile = useCallback( diff --git a/mobile/src/session/use-mobile-native-chat-prompts.test.ts b/mobile/src/session/use-mobile-native-chat-prompts.test.ts index 413fd1047fd..854d4832e95 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.test.ts @@ -2,6 +2,7 @@ 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({ @@ -14,7 +15,8 @@ function permissionFor(status: Partial | null): unknown { captured = useMobileNativeChatPrompts({ enabled: true, status: status as AgentStatusEntry | null, - messages: [] + messages: [], + transcriptLoading: false }).permission return null } @@ -60,3 +62,66 @@ describe('useMobileNativeChatPrompts approval-envelope state gate', () => { expect(permission?.options.map((o) => o.label)).toEqual(['Yes', 'No']) }) }) + +const ASK_INPUT = { + questions: [ + { question: 'Pick one', header: 'Choice', multiSelect: false, options: [{ label: 'A' }] } + ] +} + +const PENDING_ASK_MESSAGES: NativeChatMessage[] = [ + { + id: 'm1', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'AskUserQuestion', input: ASK_INPUT }], + timestamp: 0, + source: 'transcript' + } +] + +function askFor(args: { + status: Partial | null + transcriptLoading: boolean +}): unknown { + let captured: unknown + function Probe(): null { + captured = useMobileNativeChatPrompts({ + enabled: true, + status: args.status as AgentStatusEntry | null, + messages: PENDING_ASK_MESSAGES, + transcriptLoading: args.transcriptLoading + }).ask + return null + } + TestRenderer.act(() => { + TestRenderer.create(createElement(Probe)) + }) + return captured +} + +describe('useMobileNativeChatPrompts held-transcript ask gate', () => { + it('renders the transcript ask once the read has settled', () => { + expect(askFor({ status: { state: 'done' }, transcriptLoading: false })).toMatchObject({ + questions: [{ question: 'Pick one' }] + }) + }) + + it('withholds the transcript ask while the read is unsettled', () => { + // The reconnect cache keeps this list rendered across a client swap, so the + // card would otherwise resurrect after the ask was answered on the terminal + // — and it stays tappable, writing stray keystrokes to the agent's TUI. + expect(askFor({ status: { state: 'done' }, transcriptLoading: true })).toBeNull() + }) + + it('still renders a live status ask while the read is unsettled', () => { + const ask = askFor({ + status: { + state: 'waiting', + toolName: 'AskUserQuestion', + interactivePrompt: JSON.stringify(ASK_INPUT) + }, + transcriptLoading: true + }) + expect(ask).toMatchObject({ questions: [{ question: 'Pick one' }] }) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-prompts.ts b/mobile/src/session/use-mobile-native-chat-prompts.ts index 1e362030750..d36d2d99b1d 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.ts @@ -16,8 +16,11 @@ export function useMobileNativeChatPrompts(args: { enabled: boolean status: AgentStatusEntry | null | undefined messages: readonly NativeChatMessage[] + /** True while `messages` is an unsettled read (including the cached list held + * across a reconnect). Required: an ask derived from it may already be answered. */ + transcriptLoading: boolean }): MobileNativeChatPrompts { - const { enabled, status, messages } = args + const { enabled, status, messages, transcriptLoading } = args const blocked = status?.state === 'waiting' || status?.state === 'blocked' // Both permission paths sit inside the paused gate: an approval envelope can // outlive its answer (the host keeps it sticky), so only a waiting/blocked @@ -37,9 +40,12 @@ export function useMobileNativeChatPrompts(args: { () => parseAskFromStatus(status?.interactivePrompt, status?.toolName), [status?.interactivePrompt, status?.toolName] ) + // A held transcript is a cache, not settled history: the ask it still shows may + // have been answered elsewhere, and this card writes to the agent's TUI. The + // live `askFromStatus` path is unaffected, so a truly pending card stays put. const askFromMessages = useMemo( - () => (askFromStatus ? null : extractPendingAsk(messages)), - [askFromStatus, messages] + () => (askFromStatus || transcriptLoading ? null : extractPendingAsk(messages)), + [askFromStatus, transcriptLoading, messages] ) return {