mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
fix(mobile): keep native chat ask dismissals tab-scoped and gated
Dismissal state lived in the chat view subtree, which unmounts on a chat<->terminal toggle, so an answered ask card came back on return. It also had no tab scope and no waiting/blocked gate. - move dismissal into the controller, keyed per session tab - gate ask cards on waiting/blocked like the permission path already is, and retire a dismissal off the ungated detected prompt so a working/done status can't be mistaken for the prompt clearing - ignore a dismissal that settles after its prompt cleared or was replaced Refs STA-3333.
This commit is contained in:
@@ -51,6 +51,8 @@ export function MobileNativeChatOverlay({
|
||||
streamingText={controller.nativeChatStreamingText}
|
||||
onStop={controller.handleNativeChatStop}
|
||||
ask={controller.nativeChatAsk}
|
||||
askKey={controller.nativeChatAskKey}
|
||||
onDismissAsk={controller.dismissNativeChatAsk}
|
||||
onAnswerAsk={controller.handleNativeChatAnswerAsk}
|
||||
onCancelAsk={controller.handleNativeChatCancelAsk}
|
||||
question={controller.nativeChatQuestion}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
mobileNativeChatEmptyState,
|
||||
type MobileNativeChatPendingItem
|
||||
} from './mobile-native-chat-render-data'
|
||||
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
|
||||
import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture'
|
||||
import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator'
|
||||
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
|
||||
@@ -85,6 +84,11 @@ type Props = {
|
||||
/** Structured AskUserQuestion prompt parsed from the transcript (preferred over
|
||||
* the heuristic question card). */
|
||||
ask?: AskPrompt | null
|
||||
/** Stable key for the ask card. Dismissal state lives in the controller (it
|
||||
* must survive this subtree unmounting on a chat↔terminal toggle). */
|
||||
askKey?: string | null
|
||||
/** Hide the answered/dismissed ask until a different question arrives. */
|
||||
onDismissAsk?: () => void
|
||||
/** Deliver the ask answer as per-question selections; the send hook turns them
|
||||
* into selector keystrokes (Claude) or pasted label text (other agents). */
|
||||
onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise<boolean>
|
||||
@@ -130,6 +134,8 @@ export function MobileNativeChatView({
|
||||
filePaths,
|
||||
onNeedFiles,
|
||||
ask,
|
||||
askKey,
|
||||
onDismissAsk,
|
||||
onAnswerAsk,
|
||||
onCancelAsk,
|
||||
question,
|
||||
@@ -142,10 +148,6 @@ export function MobileNativeChatView({
|
||||
const insets = useSafeAreaInsets()
|
||||
const listRef = useRef<FlatList<NativeChatMessage>>(null)
|
||||
const [toolsExpanded, setToolsExpanded] = useState(false)
|
||||
// Dismiss the question card as soon as it's answered; the live status lingers
|
||||
// briefly (the agent emits a post-tool event with the same prompt), so hide it
|
||||
// until a genuinely different question arrives.
|
||||
const { askKey, showAsk, dismissAsk } = useMobileNativeChatAskDismiss(ask)
|
||||
// Lift the composer clear of the keyboard, plus the bottom safe-area so it
|
||||
// never sits under the home indicator / nav bar (mirrors the terminal dock).
|
||||
const bottomPad = keyboardInset > 0 ? keyboardInset + insets.bottom : insets.bottom
|
||||
@@ -333,22 +335,24 @@ export function MobileNativeChatView({
|
||||
</GestureHandlerRootView>
|
||||
)}
|
||||
{/* Pending agent prompt: a structured AskUserQuestion wins, then a
|
||||
heuristic permission, then a heuristic question. */}
|
||||
{showAsk && ask ? (
|
||||
heuristic permission, then a heuristic question. The controller owns
|
||||
dismissal (it must survive this subtree unmounting on a view toggle);
|
||||
`ask` arrives already nulled while dismissed. */}
|
||||
{ask ? (
|
||||
<MobileNativeChatAsk
|
||||
key={askKey ?? 'ask'}
|
||||
prompt={ask}
|
||||
onAnswer={async (selections) => {
|
||||
const accepted = (await onAnswerAsk?.(ask, selections)) ?? false
|
||||
if (accepted) {
|
||||
dismissAsk()
|
||||
onDismissAsk?.()
|
||||
}
|
||||
return accepted
|
||||
}}
|
||||
onCancel={async () => {
|
||||
const accepted = (await onCancelAsk?.()) ?? false
|
||||
if (accepted) {
|
||||
dismissAsk()
|
||||
onDismissAsk?.()
|
||||
}
|
||||
return accepted
|
||||
}}
|
||||
|
||||
@@ -18,11 +18,47 @@ describe('useMobileNativeChatAskDismiss', () => {
|
||||
state = null
|
||||
})
|
||||
|
||||
function Harness({ prompt }: { prompt: AskPrompt }): null {
|
||||
state = useMobileNativeChatAskDismiss(prompt)
|
||||
function Harness({
|
||||
prompt,
|
||||
detectedPrompt = prompt,
|
||||
scopeKey = 'tab-1',
|
||||
observing = true
|
||||
}: {
|
||||
prompt: AskPrompt | null
|
||||
detectedPrompt?: AskPrompt | null
|
||||
scopeKey?: string | null
|
||||
observing?: boolean
|
||||
}): null {
|
||||
state = useMobileNativeChatAskDismiss({
|
||||
ask: prompt,
|
||||
detectedAsk: detectedPrompt,
|
||||
scopeKey,
|
||||
observing
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
async function mount(props: Parameters<typeof Harness>[0]): Promise<void> {
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, props))
|
||||
})
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
}
|
||||
|
||||
async function update(props: Parameters<typeof Harness>[0]): Promise<void> {
|
||||
await act(async () => renderer?.update(createElement(Harness, props)))
|
||||
}
|
||||
|
||||
const first: AskPrompt = {
|
||||
questions: [
|
||||
{ question: 'same first', multiSelect: false, options: [] },
|
||||
@@ -37,24 +73,102 @@ describe('useMobileNativeChatAskDismiss', () => {
|
||||
}
|
||||
|
||||
it('shows a structurally different replacement without an intervening null', async () => {
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { prompt: first }))
|
||||
})
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
await mount({ prompt: first })
|
||||
act(() => state?.dismissAsk())
|
||||
expect(state?.showAsk).toBe(false)
|
||||
|
||||
await act(async () => renderer?.update(createElement(Harness, { prompt: replacement })))
|
||||
await update({ prompt: replacement })
|
||||
expect(state?.showAsk).toBe(true)
|
||||
|
||||
await update({ prompt: first })
|
||||
expect(state?.showAsk).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a dismissal while status gating hides a still-detected prompt', async () => {
|
||||
await mount({ prompt: first })
|
||||
const acceptedDismiss = state!.dismissAsk
|
||||
|
||||
await update({ prompt: null, detectedPrompt: first })
|
||||
act(() => acceptedDismiss())
|
||||
await update({ prompt: first })
|
||||
|
||||
expect(state?.showAsk).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores an answer that settles after its prompt cleared', async () => {
|
||||
await mount({ prompt: first })
|
||||
const lateDismiss = state!.dismissAsk
|
||||
|
||||
await update({ prompt: null })
|
||||
act(() => lateDismiss())
|
||||
await update({ prompt: first })
|
||||
|
||||
expect(state?.showAsk).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a dismissal taken on-chat across a chat→terminal→chat toggle', async () => {
|
||||
// The common case: answer the card, then toggle to the terminal. The prompt
|
||||
// derives to null while hidden, and that null must not retire the dismissal.
|
||||
await mount({ prompt: first })
|
||||
act(() => state?.dismissAsk())
|
||||
expect(state?.showAsk).toBe(false)
|
||||
|
||||
await update({ prompt: null, observing: false })
|
||||
await update({ prompt: first, observing: true })
|
||||
|
||||
expect(state?.showAsk).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a dismissal across a chat→terminal→chat toggle', async () => {
|
||||
// While the chat surface is hidden the prompt derives to null; that null
|
||||
// proves nothing about the agent and must not reset the dismissal.
|
||||
await mount({ prompt: first })
|
||||
const acceptedDismiss = state!.dismissAsk
|
||||
|
||||
await update({ prompt: null, observing: false })
|
||||
act(() => acceptedDismiss())
|
||||
await update({ prompt: first, observing: true })
|
||||
|
||||
expect(state?.showAsk).toBe(false)
|
||||
})
|
||||
|
||||
it('forgets the dismissal once the prompt clears while observable', async () => {
|
||||
await mount({ prompt: first })
|
||||
act(() => state?.dismissAsk())
|
||||
|
||||
// Agent moved on: the prompt cleared with chat visible.
|
||||
await update({ prompt: null, observing: true })
|
||||
await update({ prompt: first, observing: true })
|
||||
|
||||
expect(state?.showAsk).toBe(true)
|
||||
})
|
||||
|
||||
it('scopes the dismissal to the tab that showed the card', async () => {
|
||||
await mount({ prompt: first, scopeKey: 'tab-1' })
|
||||
act(() => state?.dismissAsk())
|
||||
|
||||
// The same question on another tab is a different pending prompt.
|
||||
await update({ prompt: first, scopeKey: 'tab-2' })
|
||||
expect(state?.showAsk).toBe(true)
|
||||
act(() => state?.dismissAsk())
|
||||
expect(state?.showAsk).toBe(false)
|
||||
|
||||
// Dismissing tab 2 must not overwrite tab 1's dismissal.
|
||||
await update({ prompt: first, scopeKey: 'tab-1' })
|
||||
expect(state?.showAsk).toBe(false)
|
||||
|
||||
await update({ prompt: first, scopeKey: 'tab-2' })
|
||||
expect(state?.showAsk).toBe(false)
|
||||
})
|
||||
|
||||
it('records a settled answer against its originating tab', async () => {
|
||||
await mount({ prompt: first, scopeKey: 'tab-1' })
|
||||
const tabOneDismiss = state!.dismissAsk
|
||||
|
||||
await update({ prompt: replacement, scopeKey: 'tab-2' })
|
||||
act(() => tabOneDismiss())
|
||||
await update({ prompt: first, scopeKey: 'tab-1' })
|
||||
|
||||
expect(state?.showAsk).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,26 +1,63 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { AskPrompt } from './mobile-native-chat-ask'
|
||||
|
||||
/** Track the answered-ask key so the lingering live status doesn't re-show the
|
||||
* same card. The agent emits a post-tool event with the same prompt right after
|
||||
* an answer, so the card is hidden until a genuinely different question arrives. */
|
||||
export function useMobileNativeChatAskDismiss(ask?: AskPrompt | null): {
|
||||
* an answer, so the card is hidden until a genuinely different question arrives.
|
||||
*
|
||||
* Owned by the controller, not the chat subtree: the overlay unmounts on a
|
||||
* chat↔terminal view toggle, and a dismissal must survive that round-trip. */
|
||||
export function useMobileNativeChatAskDismiss(args: {
|
||||
ask?: AskPrompt | null
|
||||
/** Ungated prompt payload. A working/done status hides the card but does not
|
||||
* prove the sticky prompt itself cleared. */
|
||||
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. */
|
||||
scopeKey: string | null
|
||||
/** True while the chat surface can actually observe the prompt. A null ask
|
||||
* while hidden proves nothing (prompts aren't derived off-chat) and must not
|
||||
* reset the dismissal — that reset is what resurfaced dismissed cards. */
|
||||
observing: boolean
|
||||
}): {
|
||||
askKey: string | null
|
||||
showAsk: boolean
|
||||
dismissAsk: () => void
|
||||
} {
|
||||
const { ask, detectedAsk = ask, scopeKey, observing } = args
|
||||
const askKey = ask ? JSON.stringify(ask.questions) : null
|
||||
const [dismissedAskKey, setDismissedAskKey] = useState<string | null>(null)
|
||||
// Once the prompt clears (agent moved on), forget the dismissal so a later
|
||||
// question — even an identical one — shows again instead of staying hidden.
|
||||
const askPresent = ask != null
|
||||
const detectedAskKey = detectedAsk ? JSON.stringify(detectedAsk.questions) : null
|
||||
const detectedAskKeysRef = useRef(new Map<string | null, string | null>())
|
||||
const [dismissedByScope, setDismissedByScope] = useState<Map<string | null, string>>(
|
||||
() => new Map()
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!askPresent) {
|
||||
setDismissedAskKey(null)
|
||||
if (observing) {
|
||||
detectedAskKeysRef.current.set(scopeKey, detectedAskKey)
|
||||
}
|
||||
}, [askPresent])
|
||||
const showAsk = askPresent && askKey !== dismissedAskKey
|
||||
const dismissAsk = (): void => setDismissedAskKey(askKey)
|
||||
}, [observing, detectedAskKey, scopeKey])
|
||||
// A cleared or genuinely different detected prompt retires the old dismissal.
|
||||
useEffect(() => {
|
||||
if (observing) {
|
||||
setDismissedByScope((previous) => {
|
||||
const dismissedKey = previous.get(scopeKey)
|
||||
if (dismissedKey === undefined || dismissedKey === detectedAskKey) {
|
||||
return previous
|
||||
}
|
||||
const next = new Map(previous)
|
||||
next.delete(scopeKey)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [observing, detectedAskKey, scopeKey])
|
||||
const showAsk = askKey !== null && dismissedByScope.get(scopeKey) !== askKey
|
||||
const dismissAsk = (): void => {
|
||||
// An answer may settle after the prompt cleared or was replaced. Only the
|
||||
// still-current prompt may install a lasting dismissal.
|
||||
if (askKey !== null && detectedAskKeysRef.current.get(scopeKey) === askKey) {
|
||||
setDismissedByScope((previous) => new Map(previous).set(scopeKey, askKey))
|
||||
}
|
||||
}
|
||||
|
||||
return { askKey, showAsk, dismissAsk }
|
||||
}
|
||||
|
||||
@@ -45,7 +45,12 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({
|
||||
}
|
||||
}))
|
||||
vi.mock('./use-mobile-native-chat-prompts', () => ({
|
||||
useMobileNativeChatPrompts: () => ({ permission: null, question: null, ask: null })
|
||||
useMobileNativeChatPrompts: () => ({
|
||||
permission: null,
|
||||
question: null,
|
||||
detectedAsk: null,
|
||||
ask: null
|
||||
})
|
||||
}))
|
||||
vi.mock('./use-mobile-native-chat-answer-send', () => ({
|
||||
useMobileNativeChatAnswerSend: () => ({ answerAsk: vi.fn(), cancelPending: vi.fn() })
|
||||
|
||||
@@ -20,6 +20,7 @@ import { openMobileNativeChatFile } from './mobile-native-chat-open-file'
|
||||
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
|
||||
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
|
||||
import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask'
|
||||
import {
|
||||
useMobileNativeChatDrafts,
|
||||
@@ -51,7 +52,13 @@ export type MobileNativeChatController = {
|
||||
nativeChatStreamingText?: string
|
||||
nativeChatPermission: ReturnType<typeof detectAgentPermission>
|
||||
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
|
||||
/** The pending ask, already null while dismissed (dismissal lives here so it
|
||||
* survives the chat-view subtree unmounting on a view toggle). */
|
||||
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
|
||||
/** Stable key for the current ask card (keys the card component). */
|
||||
nativeChatAskKey: string | null
|
||||
/** Hide the current ask until a genuinely different question arrives. */
|
||||
dismissNativeChatAsk: () => void
|
||||
handleNativeChatOpenFile: (relativePath: string) => void
|
||||
handleNativeChatAnswerAsk: (
|
||||
prompt: AskPrompt,
|
||||
@@ -169,12 +176,23 @@ export function useMobileNativeChatController(args: {
|
||||
const {
|
||||
permission: nativeChatPermission,
|
||||
question: nativeChatQuestion,
|
||||
ask: nativeChatAsk
|
||||
detectedAsk: nativeChatDetectedAsk,
|
||||
ask: nativeChatAskPrompt
|
||||
} = useMobileNativeChatPrompts({
|
||||
enabled: activeChatResolution != null,
|
||||
status: nativeChatStatus,
|
||||
messages: nativeChatSession.messages
|
||||
})
|
||||
const {
|
||||
askKey: nativeChatAskKey,
|
||||
showAsk: showNativeChatAsk,
|
||||
dismissAsk: dismissNativeChatAsk
|
||||
} = useMobileNativeChatAskDismiss({
|
||||
ask: nativeChatAskPrompt,
|
||||
detectedAsk: nativeChatDetectedAsk,
|
||||
scopeKey: activeSessionTabId,
|
||||
observing: showNativeChat
|
||||
})
|
||||
|
||||
const handleNativeChatOpenFile = useCallback(
|
||||
(pathText: string) => {
|
||||
@@ -275,7 +293,9 @@ export function useMobileNativeChatController(args: {
|
||||
nativeChatStreamingText,
|
||||
nativeChatPermission,
|
||||
nativeChatQuestion,
|
||||
nativeChatAsk,
|
||||
nativeChatAsk: showNativeChatAsk ? nativeChatAskPrompt : null,
|
||||
nativeChatAskKey,
|
||||
dismissNativeChatAsk,
|
||||
handleNativeChatOpenFile,
|
||||
handleNativeChatAnswerAsk: answerAsk,
|
||||
handleNativeChatCancelAsk: cancelAsk,
|
||||
|
||||
@@ -2,26 +2,38 @@ 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({
|
||||
approval: { tool: 'Bash', summary: 'pnpm build > build.log 2>&1' }
|
||||
})
|
||||
|
||||
function permissionFor(status: Partial<AgentStatusEntry> | null): unknown {
|
||||
let captured: unknown
|
||||
const ASK = JSON.stringify({
|
||||
questions: [{ question: 'Which path?', options: ['fast', 'safe'] }]
|
||||
})
|
||||
|
||||
function promptsFor(
|
||||
status: Partial<AgentStatusEntry> | null,
|
||||
messages: NativeChatMessage[] = []
|
||||
): ReturnType<typeof useMobileNativeChatPrompts> {
|
||||
let captured: ReturnType<typeof useMobileNativeChatPrompts> | undefined
|
||||
function Probe(): null {
|
||||
captured = useMobileNativeChatPrompts({
|
||||
enabled: true,
|
||||
status: status as AgentStatusEntry | null,
|
||||
messages: []
|
||||
}).permission
|
||||
messages
|
||||
})
|
||||
return null
|
||||
}
|
||||
TestRenderer.act(() => {
|
||||
TestRenderer.create(createElement(Probe))
|
||||
})
|
||||
return captured
|
||||
return captured!
|
||||
}
|
||||
|
||||
function permissionFor(status: Partial<AgentStatusEntry> | null): unknown {
|
||||
return promptsFor(status).permission
|
||||
}
|
||||
|
||||
describe('useMobileNativeChatPrompts approval-envelope state gate', () => {
|
||||
@@ -60,3 +72,45 @@ describe('useMobileNativeChatPrompts approval-envelope state gate', () => {
|
||||
expect(permission?.options.map((o) => o.label)).toEqual(['Yes', 'No'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMobileNativeChatPrompts ask state gate', () => {
|
||||
const askMessages: NativeChatMessage[] = [
|
||||
{
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
blocks: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'AskUserQuestion',
|
||||
input: { questions: [{ question: 'Which path?', options: ['fast', 'safe'] }] }
|
||||
}
|
||||
],
|
||||
timestamp: 0,
|
||||
source: 'transcript'
|
||||
}
|
||||
]
|
||||
|
||||
it('renders the ask card only while the agent is waiting or blocked', () => {
|
||||
expect(promptsFor({ state: 'waiting', interactivePrompt: ASK }).ask).toMatchObject({
|
||||
questions: [{ question: 'Which path?' }]
|
||||
})
|
||||
expect(promptsFor({ state: 'blocked', interactivePrompt: ASK }).ask).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders no ask card from a sticky prompt while the agent is working or done', () => {
|
||||
// The prompt payload outlives its answer — same paused gate as permission.
|
||||
const working = promptsFor({ state: 'working', interactivePrompt: ASK })
|
||||
expect(working.ask).toBeNull()
|
||||
expect(working.detectedAsk).not.toBeNull()
|
||||
|
||||
const done = promptsFor({ state: 'done', interactivePrompt: ASK })
|
||||
expect(done.ask).toBeNull()
|
||||
expect(done.detectedAsk).not.toBeNull()
|
||||
})
|
||||
|
||||
it('gates the transcript-derived pending ask the same way', () => {
|
||||
expect(promptsFor({ state: 'waiting' }, askMessages).ask).not.toBeNull()
|
||||
expect(promptsFor({ state: 'working' }, askMessages).ask).toBeNull()
|
||||
expect(promptsFor(null, askMessages).ask).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import { parseAgentQuestion } from './mobile-native-chat-question'
|
||||
export type MobileNativeChatPrompts = {
|
||||
permission: ReturnType<typeof detectAgentPermission>
|
||||
question: ReturnType<typeof parseAgentQuestion>
|
||||
detectedAsk: ReturnType<typeof parseAskFromStatus>
|
||||
ask: ReturnType<typeof parseAskFromStatus>
|
||||
}
|
||||
|
||||
@@ -42,9 +43,15 @@ export function useMobileNativeChatPrompts(args: {
|
||||
[askFromStatus, messages]
|
||||
)
|
||||
|
||||
const detectedAsk = askFromStatus ?? askFromMessages
|
||||
|
||||
return {
|
||||
permission,
|
||||
question,
|
||||
ask: enabled ? (askFromStatus ?? askFromMessages) : null
|
||||
detectedAsk: enabled ? detectedAsk : null,
|
||||
// Ask cards sit inside the same paused gate as the approval envelope above:
|
||||
// the prompt payload outlives its answer, so only a waiting/blocked agent
|
||||
// may surface one — never a working or done one.
|
||||
ask: enabled && blocked ? detectedAsk : null
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user