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.
This commit is contained in:
Brennan Benson
2026-08-04 02:19:41 -07:00
parent 9a3f91cd0e
commit dd803c0416
3 changed files with 77 additions and 5 deletions
@@ -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(
@@ -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<AgentStatusEntry> | 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<AgentStatusEntry> | 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' }] })
})
})
@@ -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 {