mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user