fix(mobile): answer multi-question asks like desktop and clear Claude's wait on phone answers (#33968)

* fix(runtime): clear Claude's question wait when a phone keystroke answers it

Claude sends no hook after AskUserQuestion is answered; desktop infers it, phone answers left the pane waiting.

* fix(mobile): answer multi-question cards like desktop, with Skip and back-stepping

Mobile required every answer and locked grouped structured questions once tapped; the card rules and structured mapping now live in shared code.

* fix(mobile): announce ask card options as checkboxes or radios

* fix(mobile): answer a grouped question only on the prompt it was picked on

* test(runtime): drop the multi-question case the shared answer rule already covers

* refactor(native-chat): share the typed-answer rules and fold the host answer check into terminal.send
This commit is contained in:
Kelvin Amoaba
2026-10-27 04:27:06 +00:00
committed by GitHub
parent 4c5af5d640
commit 75bcd328cf
24 changed files with 644 additions and 973 deletions
+1 -1
View File
@@ -126,7 +126,7 @@ type Props = ComposerTrayProps & {
onCollapsePrompt?: () => void
collapsedPrompt?: { title: string; expand: () => void } | null
question?: MobileChatQuestion | null
onAnswerQuestion?: (text: string) => Promise<boolean>
onAnswerQuestion?: (text: string, prompt?: MobileChatQuestion['prompt']) => Promise<boolean>
permission?: MobileChatPermission | null
promptKey?: string | null
onRespondPermission?: (send: string) => Promise<boolean>
@@ -78,7 +78,11 @@ export type MobileNativeChatController = {
nativeChatBackgroundTasks: MobileStructuredBackgroundTasks | null
nativeChatFilePaths: string[]
loadNativeChatFiles: (query: string) => void
handleNativeChatQuestionAnswer?: (text: string) => Promise<boolean>
/** A grouped structured answer names the prompt it was rendered for. */
handleNativeChatQuestionAnswer?: (
text: string,
prompt?: MobileChatQuestion['prompt']
) => Promise<boolean>
handleNativeChatSend: (text: string, images?: string[]) => Promise<boolean>
// Preserve unknown outcomes to heal orphaned image pastes; share their deadline with text and retain the original pre-paste sentAt.
handleNativeChatSendWithOutcome: (
@@ -1,48 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
projectStructuredQuestion,
type StructuredQuestionItem
} from './mobile-structured-agent-prompts'
// Grouped Claude prompts leave flat question/options as placeholders; real content lives in questions.
function groupedPrompt(): StructuredQuestionItem {
return {
itemId: 'item-1',
revision: 1,
sequence: 1,
observedAt: 1,
body: {
kind: 'question',
question: '2 grouped questions from Claude',
options: [],
questions: [
{
id: 'q1',
question: 'Which database?',
multiSelect: false,
options: [{ id: 'q1:choice-1', label: 'Postgres', description: 'Durable server' }],
freeTextQuestionId: 'q1'
},
{
id: 'q2',
question: 'Which regions?',
multiSelect: true,
options: [{ id: 'q2:choice-1', label: 'us-east' }],
freeTextQuestionId: 'q2'
}
],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
}
}
}
describe('structured question projection for grouped Claude prompts', () => {
it('renders an answerable question instead of the empty placeholder card', () => {
const projected = projectStructuredQuestion(groupedPrompt())
expect(projected?.question).toBe('Which database? (1 of 2)')
expect(projected?.options).toEqual(['Postgres'])
expect(projected?.optionDescriptions).toEqual(['Durable server'])
expect(projected?.optionTokens.filter(Boolean)).toHaveLength(1)
})
})
@@ -6,11 +6,6 @@ import {
import { chatApprovalFromJournal } from '../../../src/shared/native-chat-interactive-prompt'
import type { MobileChatPermission } from './native-chat/input/mobile-native-chat-permission'
import type { MobileChatQuestion } from './native-chat/input/mobile-native-chat-question'
import {
groupedQuestionPromptKey,
projectGroupedQuestion,
type GroupedQuestionDraft
} from './mobile-structured-grouped-question'
export type StructuredApprovalItem = AgentJournalRenderItem & {
body: Extract<AgentJournalRenderItem['body'], { kind: 'approval' }>
@@ -149,19 +144,20 @@ export function projectStructuredPermission(
}
export function projectStructuredQuestion(
prompt: StructuredQuestionItem | null,
groupedDraft: GroupedQuestionDraft | null = null
prompt: StructuredQuestionItem | null
): MobileChatQuestion | null {
if (prompt?.body.kind !== 'question') {
return null
}
if (prompt.body.questions) {
return projectGroupedQuestion(
prompt.body.questions,
groupedDraft,
groupedQuestionPromptKey(prompt.itemId, prompt.revision),
{ itemId: prompt.itemId, expectedRevision: prompt.revision }
)
return {
question: prompt.body.question,
prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision },
options: [],
multiSelect: false,
optionTokens: [],
questions: prompt.body.questions
}
}
const [question] = agentSessionPromptQuestions(prompt.body)
if (!question) {
@@ -37,7 +37,7 @@ export type StructuredMobileSession = ReturnType<typeof useMobileStructuredAgent
permission: MobileChatPermission | null
question: MobileChatQuestion | null
respondPermission: (optionId: string) => Promise<boolean>
respondQuestion: (answer: string) => Promise<boolean>
respondQuestion: (answer: string, prompt?: MobileChatQuestion['prompt']) => Promise<boolean>
cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => Promise<boolean>
queued: MobileStructuredQueuedMessageControls
commandRefusalCauses: MobileNativeChatCommandRefusalCauses
@@ -1,289 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { AgentJournalQuestion } from '../../../src/shared/agent-session-journal-types'
import {
formatQuestionAnswer,
formatQuestionFreeTextAnswer,
mobileChatQuestionKey
} from './native-chat/input/mobile-native-chat-question'
import {
advanceGroupedQuestion,
groupedQuestionPromptKey,
projectGroupedQuestion,
type GroupedQuestionDraft
} from './mobile-structured-grouped-question'
const PROMPT_KEY = groupedQuestionPromptKey('item-1', 3)
function question(overrides: Partial<AgentJournalQuestion> = {}): AgentJournalQuestion {
return {
id: 'q1',
question: 'Which database?',
multiSelect: false,
options: [
{ id: 'q1:choice-1', label: 'Postgres' },
{ id: 'q1:choice-2', label: 'SQLite' }
],
freeTextQuestionId: 'q1',
...overrides
}
}
const SECOND = question({
id: 'q2',
question: 'Which regions?',
multiSelect: true,
options: [
{ id: 'q2:choice-1', label: 'us-east' },
{ id: 'q2:choice-2', label: 'eu-west' }
],
freeTextQuestionId: 'q2'
})
/** Mirrors what the question card sends back for a single-select tap. */
function tapOption(projected: NonNullable<ReturnType<typeof projectGroupedQuestion>>, at: number) {
return projected.optionTokens[at] ?? ''
}
describe('mobile structured grouped questions', () => {
it('projects the first question with real options instead of the empty flat shape', () => {
const projected = projectGroupedQuestion([question(), SECOND], null, PROMPT_KEY)
expect(projected).toMatchObject({
question: 'Which database? (1 of 2)',
options: ['Postgres', 'SQLite'],
multiSelect: false,
allowOther: true
})
expect(projected?.optionTokens.every((token) => Boolean(token))).toBe(true)
expect(projected?.freeTextToken).toBeTruthy()
})
it('steps to the next question once the first is answered, without sending anything', () => {
const questions = [question(), SECOND]
const first = projectGroupedQuestion(questions, null, PROMPT_KEY)!
const advance = advanceGroupedQuestion({
response: tapOption(first, 0),
questions,
draft: null,
promptKey: PROMPT_KEY
})
expect(advance).toEqual({
kind: 'advance',
draft: { promptKey: PROMPT_KEY, answers: [{ questionId: 'q1', optionIds: ['q1:choice-1'] }] }
})
// Read advance non-optionally so a missing step cannot fall back to the first draft and pass.
expect(advance).toBeDefined()
const second = projectGroupedQuestion(
questions,
advance!.kind === 'advance' ? advance!.draft : null,
PROMPT_KEY
)
expect(second).toMatchObject({ question: 'Which regions? (2 of 2)', multiSelect: true })
})
it('submits the whole group as one set of answers on the last step', () => {
const questions = [question(), SECOND]
const draft: GroupedQuestionDraft = {
promptKey: PROMPT_KEY,
answers: [{ questionId: 'q1', optionIds: ['q1:choice-1'] }]
}
const second = projectGroupedQuestion(questions, draft, PROMPT_KEY)!
const result = advanceGroupedQuestion({
// Multi-select joins its selected option tokens the way the card does.
response: formatQuestionAnswer(second, ['us-east', 'eu-west']),
questions,
draft,
promptKey: PROMPT_KEY
})
expect(result?.kind).toBe('submit')
expect(result?.kind === 'submit' ? result.answers : null).toEqual([
{ questionId: 'q1', optionIds: ['q1:choice-1'] },
{ questionId: 'q2', optionIds: ['q2:choice-1', 'q2:choice-2'] }
])
})
it('carries a free-text answer as `other` for the question it was typed against', () => {
const questions = [question()]
const only = projectGroupedQuestion(questions, null, PROMPT_KEY)!
const result = advanceGroupedQuestion({
response: formatQuestionFreeTextAnswer(only, ' DuckDB '),
questions,
draft: null,
promptKey: PROMPT_KEY
})
expect(result?.kind === 'submit' ? result.answers : null).toEqual([
{ questionId: 'q1', optionIds: [], other: 'DuckDB' }
])
})
it('projects editor metadata and preserves allowed empty and whitespace answers', () => {
const editor = question({
options: [],
freeTextInput: {
allowEmpty: true,
multiline: true,
initialValue: 'draft',
placeholder: 'Edit response'
}
})
const projected = projectGroupedQuestion([editor], null, PROMPT_KEY)!
expect(projected.freeTextInput).toEqual(editor.freeTextInput)
for (const answer of ['', ' \n ']) {
const result = advanceGroupedQuestion({
response: formatQuestionFreeTextAnswer(projected, answer),
questions: [editor],
draft: null,
promptKey: PROMPT_KEY
})
expect(result).toEqual({
kind: 'submit',
answers: [{ questionId: 'q1', optionIds: [], other: answer }]
})
}
const legacy = question({ options: [] })
expect(
advanceGroupedQuestion({
response: formatQuestionFreeTextAnswer(projected, ''),
questions: [legacy],
draft: null,
promptKey: PROMPT_KEY
})
).toBeNull()
})
it('keeps selected options and other text for grouped multi-select answers', () => {
const questions = [SECOND]
const only = projectGroupedQuestion(questions, null, PROMPT_KEY)!
const result = advanceGroupedQuestion({
response: `${tapOption(only, 0)}, ${formatQuestionFreeTextAnswer(only, 'ap-south')}`,
questions,
draft: null,
promptKey: PROMPT_KEY
})
expect(result?.kind === 'submit' ? result.answers : null).toEqual([
{ questionId: 'q2', optionIds: ['q2:choice-1'], other: 'ap-south' }
])
})
it('gives each step a distinct card key so a selection cannot carry into the next question', () => {
// Identical question text must still remount by id/step or prior checkboxes become the next answer.
const questions = [
question({ id: 'q1', question: 'Approve?' }),
question({ id: 'q2', question: 'Approve?' })
]
const first = projectGroupedQuestion(questions, null, PROMPT_KEY)!
const second = projectGroupedQuestion(
questions,
{ promptKey: PROMPT_KEY, answers: [{ questionId: 'q1', optionIds: ['q1:choice-1'] }] },
PROMPT_KEY
)!
expect(first.question).toBe('Approve? (1 of 2)')
expect(second.question).toBe('Approve? (2 of 2)')
expect(mobileChatQuestionKey(first)).not.toBe(mobileChatQuestionKey(second))
})
it('discards a draft collected against a superseded prompt revision', () => {
const questions = [question(), SECOND]
const stale: GroupedQuestionDraft = {
promptKey: groupedQuestionPromptKey('item-1', 2),
answers: [{ questionId: 'q1', optionIds: ['q1:choice-1'] }]
}
expect(projectGroupedQuestion(questions, stale, PROMPT_KEY)).toMatchObject({
question: 'Which database? (1 of 2)'
})
})
it('refuses a response that does not answer the current step', () => {
const questions = [question(), SECOND]
expect(
advanceGroupedQuestion({
response: 'Postgres',
questions,
draft: null,
promptKey: PROMPT_KEY
})
).toBeNull()
})
it('refuses an option token rendered for a superseded prompt revision', () => {
const stale = projectGroupedQuestion([question()], null, groupedQuestionPromptKey('item-1', 2))!
expect(
advanceGroupedQuestion({
response: tapOption(stale, 0),
questions: [question()],
draft: null,
promptKey: PROMPT_KEY
})
).toBeNull()
})
it('refuses free text rendered for a superseded prompt revision', () => {
const stale = projectGroupedQuestion([question()], null, groupedQuestionPromptKey('item-1', 2))!
expect(
advanceGroupedQuestion({
response: formatQuestionFreeTextAnswer(stale, 'stale answer'),
questions: [question()],
draft: null,
promptKey: PROMPT_KEY
})
).toBeNull()
})
it('rejects a multi-select response when one selected token is malformed', () => {
const questions = [SECOND]
const only = projectGroupedQuestion(questions, null, PROMPT_KEY)!
expect(
advanceGroupedQuestion({
response: `${tapOption(only, 0)}, not-a-grouped-token`,
questions,
draft: null,
promptKey: PROMPT_KEY
})
).toBeNull()
})
it('rejects a multi-select response when one selected token belongs to another prompt', () => {
const questions = [SECOND]
const current = projectGroupedQuestion(questions, null, PROMPT_KEY)!
const stale = projectGroupedQuestion(questions, null, groupedQuestionPromptKey('item-1', 2))!
expect(
advanceGroupedQuestion({
response: `${tapOption(current, 0)}, ${tapOption(stale, 1)}`,
questions,
draft: null,
promptKey: PROMPT_KEY
})
).toBeNull()
})
it('refuses an empty multi-select rather than sending a group the host would reject', () => {
const questions = [SECOND]
const only = projectGroupedQuestion(questions, null, PROMPT_KEY)!
expect(
advanceGroupedQuestion({
response: formatQuestionAnswer(only, []),
questions,
draft: null,
promptKey: PROMPT_KEY
})
).toBeNull()
})
})
@@ -1,211 +0,0 @@
import type { AgentJournalQuestion } from '../../../src/shared/agent-session-journal-types'
import {
isValidAgentSessionQuestionAnswers,
type AgentSessionQuestionAnswer
} from '../../../src/shared/agent-session-question-answer'
import type { MobileChatQuestion } from './native-chat/input/mobile-native-chat-question'
// Grouped prompts put content in questions and leave flat options empty; collect steps locally and submit the whole answer set once.
export type GroupedQuestionDraft = {
/** Identifies the exact prompt revision these answers belong to; a revised prompt discards them. */
promptKey: string
answers: AgentSessionQuestionAnswer[]
}
export type GroupedQuestionAdvance =
| { kind: 'advance'; draft: GroupedQuestionDraft }
| { kind: 'submit'; answers: AgentSessionQuestionAnswer[] }
const GROUPED_TOKEN_PREFIX = 'structured-grouped-question:'
type GroupedTokenPayload =
| { kind: 'option'; promptKey: string; questionId: string; optionId: string }
| { kind: 'free-text'; promptKey: string; questionId: string }
export function groupedQuestionPromptKey(itemId: string, revision: number): string {
return `${itemId}:${revision}`
}
function encodeGroupedToken(payload: GroupedTokenPayload): string {
return `${GROUPED_TOKEN_PREFIX}${encodeURIComponent(JSON.stringify(payload))}`
}
function decodeGroupedToken(value: string): GroupedTokenPayload | null {
if (!value.startsWith(GROUPED_TOKEN_PREFIX)) {
return null
}
try {
const decoded = JSON.parse(
decodeURIComponent(value.slice(GROUPED_TOKEN_PREFIX.length))
) as Record<string, unknown>
if (typeof decoded.promptKey !== 'string' || typeof decoded.questionId !== 'string') {
return null
}
if (decoded.kind === 'option' && typeof decoded.optionId === 'string') {
return {
kind: 'option',
promptKey: decoded.promptKey,
questionId: decoded.questionId,
optionId: decoded.optionId
}
}
if (decoded.kind === 'free-text') {
return { kind: 'free-text', promptKey: decoded.promptKey, questionId: decoded.questionId }
}
} catch {
return null
}
return null
}
function decodeGroupedFreeTextAnswer(value: string): {
promptKey: string
questionId: string
answer: string
} | null {
if (!value.startsWith(GROUPED_TOKEN_PREFIX)) {
return null
}
// The payload is percent-encoded, so the first `:` after the prefix is the answer separator.
const separator = value.indexOf(':', GROUPED_TOKEN_PREFIX.length)
if (separator === -1) {
return null
}
const payload = decodeGroupedToken(value.slice(0, separator))
if (payload?.kind !== 'free-text') {
return null
}
try {
return {
promptKey: payload.promptKey,
questionId: payload.questionId,
answer: decodeURIComponent(value.slice(separator + 1))
}
} catch {
return null
}
}
function answersFor(
draft: GroupedQuestionDraft | null,
promptKey: string
): AgentSessionQuestionAnswer[] {
return draft && draft.promptKey === promptKey ? draft.answers : []
}
export function projectGroupedQuestion(
questions: readonly AgentJournalQuestion[],
draft: GroupedQuestionDraft | null,
promptKey: string,
promptIdentity?: { itemId: string; expectedRevision: number }
): MobileChatQuestion | null {
const answered = answersFor(draft, promptKey).length
const question = questions[answered]
if (!question) {
return null
}
const heading = question.header ? `${question.header}: ${question.question}` : question.question
const optionDescriptions = question.options.map((option) => option.description)
return {
question:
questions.length > 1 ? `${heading} (${answered + 1} of ${questions.length})` : heading,
...(promptIdentity ? { prompt: promptIdentity } : {}),
options: question.options.map((option) => option.label),
...(optionDescriptions.some(Boolean) ? { optionDescriptions } : {}),
multiSelect: question.multiSelect,
allowOther: Boolean(question.freeTextQuestionId),
...(question.freeTextInput ? { freeTextInput: question.freeTextInput } : {}),
optionTokens: question.options.map((option) =>
encodeGroupedToken({
kind: 'option',
promptKey,
questionId: question.id,
optionId: option.id
})
),
...(question.freeTextQuestionId
? {
freeTextToken: encodeGroupedToken({
kind: 'free-text',
promptKey,
questionId: question.id
})
}
: {})
}
}
function answerFromResponse(
response: string,
question: AgentJournalQuestion,
promptKey: string
): AgentSessionQuestionAnswer | null {
// Multi-select submits comma-joined parts; tokens and free text are encoded, so the separator is stable.
const optionIds: string[] = []
let other: string | undefined
for (const part of response.split(', ')) {
const trimmed = part.trim()
const freeText = decodeGroupedFreeTextAnswer(trimmed)
if (freeText) {
const allowEmpty = question.freeTextInput?.allowEmpty === true
const answer = allowEmpty ? freeText.answer : freeText.answer.trim()
if (
freeText.promptKey !== promptKey ||
freeText.questionId !== question.id ||
(answer.length === 0 && !allowEmpty) ||
other !== undefined
) {
return null
}
other = answer
continue
}
const payload = decodeGroupedToken(trimmed)
if (
payload?.kind !== 'option' ||
payload.promptKey !== promptKey ||
payload.questionId !== question.id
) {
return null
}
optionIds.push(payload.optionId)
}
const offered = new Set(question.options.map((option) => option.id))
if (optionIds.some((optionId) => !offered.has(optionId))) {
return null
}
if (other !== undefined && !question.freeTextQuestionId) {
return null
}
const answerCount = optionIds.length + (other !== undefined ? 1 : 0)
if (answerCount === 0 || (!question.multiSelect && answerCount !== 1)) {
return null
}
return { questionId: question.id, optionIds, ...(other !== undefined ? { other } : {}) }
}
export function advanceGroupedQuestion(args: {
response: string
questions: readonly AgentJournalQuestion[]
draft: GroupedQuestionDraft | null
promptKey: string
}): GroupedQuestionAdvance | null {
const collected = answersFor(args.draft, args.promptKey)
const question = args.questions[collected.length]
if (!question) {
return null
}
const answer = answerFromResponse(args.response, question, args.promptKey)
if (!answer) {
return null
}
const answers = [...collected, answer]
if (answers.length < args.questions.length) {
return { kind: 'advance', draft: { promptKey: args.promptKey, answers } }
}
// Validate before submission so an unanswerable refusal cannot strand the group.
return isValidAgentSessionQuestionAnswers(args.questions, answers)
? { kind: 'submit', answers }
: null
}
@@ -1,9 +1,11 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AskPrompt } from '../../../../../src/shared/native-chat-ask-types'
import type { AskPrompt } from '../../../../../src/shared/native-chat-ask'
import { NATIVE_CHAT_QUESTION_AUTO_ADVANCE_MS } from '../../../../../src/shared/native-chat-question-auto-advance'
import { encodeAgentSessionQuestionAnswers } from '../../../../../src/shared/agent-session-question-answer'
import { MobileNativeChatAsk } from './MobileNativeChatAsk'
import { MobileNativeChatStructuredAsk } from './MobileNativeChatStructuredAsk'
vi.mock('react-native', () => ({
Keyboard: { dismiss: () => {} },
@@ -47,10 +49,10 @@ function press(label: string): void {
act(() => row.props.onPress())
}
async function submit(): Promise<void> {
async function submit(label = 'Submit'): Promise<void> {
const button = tree.root.find(
(node) =>
isHost(node, 'Pressable') && node.findAll((n) => n.props.children === 'Submit').length > 0
isHost(node, 'Pressable') && node.findAll((n) => n.props.children === label).length > 0
)
expect(button.props.disabled).toBe(false)
await act(async () => button.props.onPress())
@@ -130,6 +132,30 @@ describe('MobileNativeChatAsk single-select auto-advance', () => {
expect(onAnswer).toHaveBeenCalledExactlyOnceWith([{ indices: [0] }])
})
it('sends the answered questions when the last one is skipped', async () => {
const onAnswer = render({ questions: [indent, fruit] })
press('Tabs')
await passAutoAdvanceBeat()
await submit('Skip')
expect(onAnswer).toHaveBeenCalledExactlyOnceWith([{ indices: [0] }, { indices: [] }])
})
it('cancels the prompt when every question is skipped', async () => {
const onCancel = vi.fn(async () => true)
const onAnswer = render(
{ questions: [indent] },
vi.fn(async () => true),
onCancel
)
await submit('Skip')
expect(onCancel).toHaveBeenCalledOnce()
expect(onAnswer).not.toHaveBeenCalled()
})
it('sends nothing when the question is cancelled before the pick moves on', async () => {
const onCancel = vi.fn(async () => true)
const onAnswer = render(
@@ -150,3 +176,52 @@ describe('MobileNativeChatAsk single-select auto-advance', () => {
expect(onAnswer).not.toHaveBeenCalled()
})
})
describe('MobileNativeChatStructuredAsk', () => {
it('lets an earlier question change before the whole set is sent', async () => {
vi.useFakeTimers()
const onAnswer = vi.fn(async () => true)
const options = (id: string, labels: string[]) =>
labels.map((label, i) => ({ id: `${id}:${i}`, label }))
act(() => {
tree = create(
createElement(MobileNativeChatStructuredAsk, {
questions: [
{
id: 'q1',
question: 'Tabs or spaces?',
multiSelect: false,
options: options('q1', ['Tabs', 'Spaces'])
},
{
id: 'q2',
question: 'Which fruit?',
multiSelect: false,
options: options('q2', ['Apple', 'Banana'])
}
],
onAnswer
})
)
})
press('Tabs')
await passAutoAdvanceBeat()
const firstTab = tree.root.find(
(node) =>
isHost(node, 'Pressable') && node.findAll((n) => n.props.children === 'Step 1').length > 0
)
act(() => firstTab.props.onPress())
press('Spaces')
await passAutoAdvanceBeat()
press('Banana')
await passAutoAdvanceBeat()
expect(onAnswer).toHaveBeenCalledExactlyOnceWith(
encodeAgentSessionQuestionAnswers([
{ questionId: 'q1', optionIds: ['q1:1'] },
{ questionId: 'q2', optionIds: ['q2:1'] }
])
)
})
})
@@ -1,12 +1,20 @@
import { useMemo, useRef, useState } from 'react'
import { useRef, useState } from 'react'
import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'
import { Check } from 'lucide-react-native'
import type { AskAnswerSelection } from '../../../../../src/shared/native-chat-ask-answer'
import type { AskPrompt } from '../../../../../src/shared/native-chat-ask-types'
import type { AskAnswerSelection, AskPrompt } from '../../../../../src/shared/native-chat-ask'
import type { AgentJournalFreeTextInput } from '../../../../../src/shared/agent-session-journal-types'
import {
askAcceptsEmptyAnswer,
askConfirmAction,
askConfirmLabel,
askTypedText,
toggleAskOption
} from '../../../../../src/shared/native-chat-ask-card-rules'
import { colors, radii, spacing, typography } from '../../../theme/mobile-theme'
import { TEXT_INPUT_FONT_SIZE } from '../../../platform/text-input-font-size'
import { MobileNativeChatCardHeaderAction } from '../../MobileNativeChatCardHeaderAction'
import { useMobileNativeChatAskAutoAdvance } from './use-mobile-native-chat-ask-auto-advance'
import { MobileNativeChatAskOptionRow } from './MobileNativeChatAskOptionRow'
type Props = {
prompt: AskPrompt
@@ -15,8 +23,13 @@ type Props = {
onCancel?: () => Promise<boolean>
// Collapse writes nothing to the agent.
onCollapse?: () => void
/** Per question; structured prompts hide "Other…" where the provider takes no free text. */
allowOther?: readonly boolean[]
freeTextInputs?: readonly (AgentJournalFreeTextInput | undefined)[]
}
const CONFIRM_LABEL = { submit: 'Submit', next: 'Next', skip: 'Skip' } as const
// Sentinel index for the free-text "Other…" row (never a real option index).
const OTHER = -1
@@ -24,15 +37,25 @@ export function MobileNativeChatAsk({
prompt,
onAnswer,
onCancel,
onCollapse
onCollapse,
allowOther,
freeTextInputs
}: Props): React.JSX.Element {
const [index, setIndex] = useState(0)
const [selections, setSelections] = useState<number[][]>(() => prompt.questions.map(() => []))
const [otherText, setOtherText] = useState<string[]>(() => prompt.questions.map(() => ''))
const [otherText, setOtherText] = useState<string[]>(() =>
prompt.questions.map((_, qi) => freeTextInputs?.[qi]?.initialValue ?? '')
)
const [submitting, setSubmitting] = useState(false)
const submittingRef = useRef(false)
const autoAdvance = useMobileNativeChatAskAutoAdvance()
// A question with no options is answered by typing alone.
const typesOnly = (qi: number): boolean =>
allowOther?.[qi] !== false && prompt.questions[qi]?.options.length === 0
const acceptsEmpty = (qi: number): boolean =>
askAcceptsEmptyAnswer(prompt.questions[qi], freeTextInputs?.[qi])
// Delay single-select advance to show the choice; Other still needs text.
const toggle = (qi: number, optIndex: number, multi: boolean): void => {
// Block same-frame taps before disabled state renders.
@@ -41,12 +64,7 @@ export function MobileNativeChatAsk({
}
const cur = selections[qi] ?? []
const picked = !cur.includes(optIndex)
const chosen = !picked
? cur.filter((i) => i !== optIndex)
: multi
? [...cur, optIndex]
: [optIndex]
const next = selections.map((s, i) => (i === qi ? chosen : s))
const next = selections.map((s, i) => (i === qi ? toggleAskOption(cur, optIndex, multi) : s))
setSelections(next)
autoAdvance.cancel()
if (picked && !multi && optIndex !== OTHER) {
@@ -65,26 +83,23 @@ export function MobileNativeChatAsk({
// Pass the new selection explicitly because setState has not committed it yet.
const selectionFor = (qi: number, sel = selections): AskAnswerSelection => {
const picked = (sel[qi] ?? []).filter((i) => i !== OTHER)
const other = (sel[qi] ?? []).includes(OTHER) ? (otherText[qi] ?? '').trim() : ''
return other ? { indices: picked, other } : { indices: picked }
const typed = typesOnly(qi) || (sel[qi] ?? []).includes(OTHER)
const other = typed ? askTypedText(otherText[qi] ?? '', freeTextInputs?.[qi]) : ''
return other || acceptsEmpty(qi) ? { indices: picked, other } : { indices: picked }
}
const isAnswered = (qi: number, sel = selections): boolean => {
const answer = selectionFor(qi, sel)
return answer.indices.length > 0 || (answer.other ?? '').length > 0
return answer.indices.length > 0 || (answer.other ?? '').length > 0 || acceptsEmpty(qi)
}
const total = prompt.questions.length
const isLast = index === total - 1
const currentAnswered = useMemo(() => isAnswered(index), [selections, otherText, index])
const allAnswered = useMemo(
() => prompt.questions.every((_, i) => isAnswered(i)),
[otherText, prompt.questions, selections]
)
const canAdvance = !submitting && (isLast ? allAnswered : currentAnswered)
const anyAnswered = (sel = selections): boolean =>
prompt.questions.some((_, i) => isAnswered(i, sel))
const submit = async (sel: number[][]): Promise<void> => {
if (!prompt.questions.every((_, i) => isAnswered(i, sel)) || submittingRef.current) {
if (!anyAnswered(sel) || submittingRef.current) {
return
}
submittingRef.current = true
@@ -106,8 +121,34 @@ export function MobileNativeChatAsk({
}
}
const cancel = async (): Promise<void> => {
autoAdvance.cancel()
if (!submittingRef.current && onCancel) {
submittingRef.current = true
setSubmitting(true)
try {
await onCancel()
} finally {
submittingRef.current = false
setSubmitting(false)
}
}
}
const confirm = (): void => {
const action = askConfirmAction({ isLast, anyAnswered: anyAnswered() })
if (action === 'cancel') {
void cancel()
} else if (action === 'proceed') {
void advance()
}
}
const q = prompt.questions[index]!
const otherAllowed = allowOther?.[index] !== false
const otherSelected = (selections[index] ?? []).includes(OTHER)
const freeTextInput = freeTextInputs?.[index]
const currentAnswered = isAnswered(index)
return (
<View style={styles.card}>
@@ -156,7 +197,7 @@ export function MobileNativeChatAsk({
/>
</View>
{q.options.map((opt, optIndex) => (
<OptionRow
<MobileNativeChatAskOptionRow
key={`${optIndex}:${opt.label}`}
label={opt.label}
description={opt.description}
@@ -166,21 +207,24 @@ export function MobileNativeChatAsk({
onPress={() => toggle(index, optIndex, q.multiSelect)}
/>
))}
<OptionRow
label="Other…"
selected={otherSelected}
multi={q.multiSelect}
disabled={submitting}
onPress={() => toggle(index, OTHER, q.multiSelect)}
/>
{otherSelected ? (
{otherAllowed && !typesOnly(index) ? (
<MobileNativeChatAskOptionRow
label="Other…"
selected={otherSelected}
multi={q.multiSelect}
disabled={submitting}
onPress={() => toggle(index, OTHER, q.multiSelect)}
/>
) : null}
{otherSelected || typesOnly(index) ? (
<TextInput
style={styles.input}
value={otherText[index]}
onChangeText={(v) => setOther(index, v)}
placeholder="Type your answer"
placeholder={freeTextInput?.placeholder ?? 'Type your answer'}
placeholderTextColor={colors.textMuted}
multiline
// A declared input (Pi's `input`) is one line unless it asks for more, as on desktop.
multiline={freeTextInput ? freeTextInput.multiline === true : true}
autoFocus
/>
) : null}
@@ -189,19 +233,7 @@ export function MobileNativeChatAsk({
<View style={styles.footer}>
<Pressable
style={styles.cancel}
onPress={async () => {
autoAdvance.cancel()
if (!submittingRef.current && onCancel) {
submittingRef.current = true
setSubmitting(true)
try {
await onCancel()
} finally {
submittingRef.current = false
setSubmitting(false)
}
}
}}
onPress={() => void cancel()}
disabled={submitting}
hitSlop={8}
>
@@ -213,12 +245,12 @@ export function MobileNativeChatAsk({
</Text>
) : null}
<Pressable
style={[styles.next, !canAdvance && styles.nextDisabled]}
onPress={() => void advance()}
disabled={!canAdvance}
style={[styles.next, !currentAnswered && styles.nextSkip]}
onPress={confirm}
disabled={submitting}
>
<Text style={[styles.nextText, !canAdvance && styles.nextTextDisabled]}>
{isLast ? 'Submit' : 'Next'}
<Text style={[styles.nextText, !currentAnswered && styles.nextSkipText]}>
{CONFIRM_LABEL[askConfirmLabel(currentAnswered, isLast)]}
</Text>
</Pressable>
</View>
@@ -226,49 +258,6 @@ export function MobileNativeChatAsk({
)
}
function OptionRow({
label,
description,
selected,
multi,
disabled,
onPress
}: {
label: string
description?: string
selected: boolean
multi?: boolean
disabled: boolean
onPress: () => void
}): React.JSX.Element {
return (
<Pressable
style={[styles.option, selected && styles.optionSelected]}
disabled={disabled}
onPress={onPress}
>
{/* Multi-select reads as a checkbox (square); single-select as a radio (circle). */}
<View
style={[
styles.check,
multi ? styles.checkSquare : styles.checkCircle,
selected && styles.checkOn
]}
>
{selected ? <Check size={12} color={colors.bgBase} strokeWidth={3} /> : null}
</View>
<View style={styles.optionBody}>
<Text style={styles.optionLabel}>{label}</Text>
{description ? (
<Text style={styles.optionDescription} numberOfLines={3}>
{description}
</Text>
) : null}
</View>
</Pressable>
)
}
const styles = StyleSheet.create({
card: {
maxHeight: 380,
@@ -319,51 +308,6 @@ const styles = StyleSheet.create({
fontWeight: '600',
marginVertical: spacing.sm
},
option: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
padding: spacing.sm,
borderRadius: radii.card,
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
marginBottom: spacing.xs
},
optionSelected: {
borderColor: colors.statusGreen
},
check: {
width: 18,
height: 18,
borderWidth: 1.5,
borderColor: colors.textMuted,
alignItems: 'center',
justifyContent: 'center'
},
checkCircle: {
borderRadius: 9
},
checkSquare: {
borderRadius: 4
},
checkOn: {
backgroundColor: colors.statusGreen,
borderColor: colors.statusGreen
},
optionBody: {
flex: 1,
gap: 2
},
optionLabel: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
optionDescription: {
color: colors.textSecondary,
fontSize: typography.metaSize
},
input: {
backgroundColor: colors.bgRaised,
borderWidth: 1,
@@ -403,7 +347,7 @@ const styles = StyleSheet.create({
borderRadius: radii.button,
backgroundColor: colors.textPrimary
},
nextDisabled: {
nextSkip: {
backgroundColor: colors.bgRaised
},
nextText: {
@@ -411,7 +355,7 @@ const styles = StyleSheet.create({
fontSize: typography.bodySize,
fontWeight: '700'
},
nextTextDisabled: {
nextSkipText: {
color: colors.textMuted
}
})
@@ -0,0 +1,96 @@
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { Check } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../../../theme/mobile-theme'
export function MobileNativeChatAskOptionRow({
label,
description,
selected,
multi,
disabled,
onPress
}: {
label: string
description?: string
selected: boolean
multi?: boolean
disabled: boolean
onPress: () => void
}): React.JSX.Element {
return (
<Pressable
accessibilityRole={multi ? 'checkbox' : 'radio'}
accessibilityState={{ checked: selected, disabled }}
style={[styles.option, selected && styles.optionSelected]}
disabled={disabled}
onPress={onPress}
>
{/* Multi-select reads as a checkbox (square); single-select as a radio (circle). */}
<View
style={[
styles.check,
multi ? styles.checkSquare : styles.checkCircle,
selected && styles.checkOn
]}
>
{selected ? <Check size={12} color={colors.bgBase} strokeWidth={3} /> : null}
</View>
<View style={styles.optionBody}>
<Text style={styles.optionLabel}>{label}</Text>
{description ? (
<Text style={styles.optionDescription} numberOfLines={3}>
{description}
</Text>
) : null}
</View>
</Pressable>
)
}
const styles = StyleSheet.create({
option: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
padding: spacing.sm,
borderRadius: radii.card,
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
marginBottom: spacing.xs
},
optionSelected: {
borderColor: colors.statusGreen
},
check: {
width: 18,
height: 18,
borderWidth: 1.5,
borderColor: colors.textMuted,
alignItems: 'center',
justifyContent: 'center'
},
checkCircle: {
borderRadius: 9
},
checkSquare: {
borderRadius: 4
},
checkOn: {
backgroundColor: colors.statusGreen,
borderColor: colors.statusGreen
},
optionBody: {
flex: 1,
gap: 2
},
optionLabel: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
optionDescription: {
color: colors.textSecondary,
fontSize: typography.metaSize
}
})
@@ -6,6 +6,7 @@ import { MobileNativeChatAsk } from './MobileNativeChatAsk'
import { MobileNativeChatPermission } from './MobileNativeChatPermission'
import type { MobileChatPermission } from './mobile-native-chat-permission'
import { MobileNativeChatQuestion } from './MobileNativeChatQuestion'
import { MobileNativeChatStructuredAsk } from './MobileNativeChatStructuredAsk'
import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question'
import { MobileNativeChatPromptStrip } from './MobileNativeChatPromptStrip'
@@ -41,7 +42,7 @@ export function MobileNativeChatPromptCard({
permission?: MobileChatPermission | null
onRespondPermission?: (send: string) => Promise<boolean>
question?: MobileChatQuestion | null
onAnswerQuestion?: (text: string) => Promise<boolean>
onAnswerQuestion?: (text: string, prompt?: MobileChatQuestion['prompt']) => Promise<boolean>
}): React.JSX.Element | null {
const card =
ask && requiresTerminal ? (
@@ -87,6 +88,13 @@ export function MobileNativeChatPromptCard({
onCancel={onCancelPrompt}
onCollapse={onCollapsePrompt}
/>
) : question?.questions ? (
<MobileNativeChatStructuredAsk
key={mobileChatQuestionKey(question)}
questions={question.questions}
onAnswer={async (answers) => (await onAnswerQuestion?.(answers, question.prompt)) ?? false}
onCancel={onCancelPrompt && (() => onCancelPrompt(question.prompt))}
/>
) : question ? (
<MobileNativeChatQuestion
key={mobileChatQuestionKey(question)}
@@ -0,0 +1,30 @@
import type { AgentJournalQuestion } from '../../../../../src/shared/agent-session-journal-types'
import { encodeAgentSessionQuestionAnswers } from '../../../../../src/shared/agent-session-question-answer'
import {
structuredQuestionAnswers,
structuredQuestionCard
} from '../../../../../src/shared/structured-agent-question-card'
import { MobileNativeChatAsk } from './MobileNativeChatAsk'
/** Grouped structured prompts use the terminal ask card, as on desktop. */
export function MobileNativeChatStructuredAsk({
questions,
onAnswer,
onCancel
}: {
questions: readonly AgentJournalQuestion[]
/** Receives the whole answer set in the shared answer encoding. */
onAnswer: (encodedAnswers: string) => Promise<boolean>
onCancel?: () => Promise<boolean>
}): React.JSX.Element {
return (
<MobileNativeChatAsk
{...structuredQuestionCard(questions)}
onAnswer={async (selections) => {
const answers = structuredQuestionAnswers(questions, selections)
return answers ? onAnswer(encodeAgentSessionQuestionAnswers(answers)) : false
}}
onCancel={onCancel}
/>
)
}
@@ -1,4 +1,7 @@
import type { AgentJournalFreeTextInput } from '../../../../../src/shared/agent-session-journal-types'
import type {
AgentJournalFreeTextInput,
AgentJournalQuestion
} from '../../../../../src/shared/agent-session-journal-types'
export type MobileChatQuestion = {
question: string
@@ -14,6 +17,8 @@ export type MobileChatQuestion = {
/** Opaque prefix used when free-text answers must target a specific prompt. */
freeTextToken?: string
freeTextInput?: AgentJournalFreeTextInput
/** A grouped structured prompt, answered on the stepped ask card. */
questions?: readonly AgentJournalQuestion[]
}
export function mobileChatQuestionKey(question: MobileChatQuestion): string {
@@ -8,7 +8,6 @@ import type { RpcClient } from '../transport/rpc-client'
const mocks = vi.hoisted(() => ({
sendRequest: vi.fn(),
promptResponses: vi.fn(() => ({
groupedDraft: null,
respondPermission: vi.fn(),
respondQuestion: vi.fn()
}))
@@ -115,9 +115,8 @@ export function useMobileStructuredAgentSession(
[conversationCommands, invokeStructuredOption, optionSnapshot, setStructuredOption]
)
const { groupedDraft, respondPermission, respondQuestion } = useMobileStructuredPromptResponses({
const { respondPermission, respondQuestion } = useMobileStructuredPromptResponses({
stateRef,
sessionKey,
mutate,
questionAnswersSupported: hostSupport?.questionAnswers ?? null,
onSendError
@@ -300,7 +299,7 @@ export function useMobileStructuredAgentSession(
receipts: structuredQuestionTranscript(transcriptItems).receipts,
actionablePromptIds: state.actionablePromptIds,
permission: projectStructuredPermission(approvalPrompt),
question: projectStructuredQuestion(questionPrompt, groupedDraft),
question: projectStructuredQuestion(questionPrompt),
respondPermission,
respondQuestion,
queued,
@@ -1,8 +1,11 @@
import { createElement, useRef } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionPromptResult } from '../../../src/shared/agent-session-wire'
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
import {
encodeAgentSessionQuestionAnswers,
type AgentSessionQuestionAnswer
} from '../../../src/shared/agent-session-question-answer'
import {
EMPTY_STRUCTURED_AGENT_SESSION,
type StructuredAgentSessionState
@@ -12,11 +15,7 @@ import {
projectStructuredQuestion,
type StructuredQuestionItem
} from './mobile-structured-agent-prompts'
import type {
StructuredAgentSessionMutate,
StructuredAgentSessionMutationResult
} from './mobile-structured-agent-session-rpc'
import { groupedQuestionPromptKey } from './mobile-structured-grouped-question'
import type { StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc'
import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses'
type PromptResponses = ReturnType<typeof useMobileStructuredPromptResponses>
@@ -63,17 +62,7 @@ function sessionState(prompt: AgentJournalRenderItem): StructuredAgentSessionSta
return { ...EMPTY_STRUCTURED_AGENT_SESSION, status: 'ready', items: [prompt] }
}
function projectedResponse(prompt: StructuredQuestionItem, draft: PromptResponses['groupedDraft']) {
const projected = projectStructuredQuestion(prompt, draft)
const response = projected?.optionTokens[0]
if (!response) {
throw new Error('Grouped question did not project an option response')
}
return response
}
function Probe(props: {
sessionKey: string
state: StructuredAgentSessionState
mutate: StructuredAgentSessionMutate
questionAnswersSupported?: boolean | null
@@ -83,7 +72,6 @@ function Probe(props: {
stateRef.current = props.state
currentHook = useMobileStructuredPromptResponses({
stateRef,
sessionKey: props.sessionKey,
mutate: props.mutate,
questionAnswersSupported: props.questionAnswersSupported ?? null,
onSendError: props.onSendError ?? vi.fn()
@@ -105,81 +93,6 @@ afterEach(() => {
})
describe('useMobileStructuredPromptResponses', () => {
it.each([
['another session', 'session-b', groupedPrompt('item-b', 1)],
['a newer prompt revision', 'session-a', groupedPrompt('item-a', 2)]
])(
'does not let a completed grouped response clear %s draft',
async (_, nextSession, nextPrompt) => {
const firstPrompt = groupedPrompt('item-a', 1)
let resolveMutation!: (
value: StructuredAgentSessionMutationResult<AgentSessionPromptResult>
) => void
const pendingMutation = new Promise<
StructuredAgentSessionMutationResult<AgentSessionPromptResult>
>((resolve) => {
resolveMutation = resolve
})
const mutate = vi.fn(() => pendingMutation) as unknown as StructuredAgentSessionMutate
act(() => {
renderer = create(
createElement(Probe, {
sessionKey: 'session-a',
state: sessionState(firstPrompt),
mutate
})
)
})
await act(async () => {
await hook().respondQuestion(projectedResponse(firstPrompt, null))
})
let firstSubmission!: Promise<boolean>
act(() => {
firstSubmission = hook().respondQuestion(
projectedResponse(firstPrompt, hook().groupedDraft)
)
})
act(() => {
renderer?.update(
createElement(Probe, {
sessionKey: nextSession,
state: sessionState(nextPrompt),
mutate
})
)
})
await act(async () => {
await hook().respondQuestion(projectedResponse(nextPrompt, null))
})
expect(hook().groupedDraft?.answers).toHaveLength(1)
await act(async () => {
resolveMutation({
status: 'accepted',
value: {
itemId: firstPrompt.itemId,
revision: firstPrompt.revision,
resolution: {
state: 'resolved',
selectedOptionId: 'q2:choice-1',
resolvedBy: 'mobile',
resolvedAt: 2
}
},
sameFence: true
})
await firstSubmission
})
expect(hook().groupedDraft?.promptKey).toBe(
groupedQuestionPromptKey(nextPrompt.itemId, nextPrompt.revision)
)
expect(hook().groupedDraft?.answers).toHaveLength(1)
}
)
describe('answer wire', () => {
const LONG_ANSWER = 'Proceed with the replacement, but wait for the capture. '.repeat(30).trim()
@@ -225,7 +138,6 @@ describe('useMobileStructuredPromptResponses', () => {
act(() => {
renderer = create(
createElement(Probe, {
sessionKey: 'session-a',
state: sessionState(prompt),
mutate,
questionAnswersSupported,
@@ -353,22 +265,37 @@ describe('useMobileStructuredPromptResponses', () => {
}
)
const RENDERED_GROUP = { itemId: 'item-g', expectedRevision: 1 }
it('refuses grouped answers picked on a prompt that has since been replaced', async () => {
const { mutate, sent } = recordingMutate()
mount(groupedPrompt('item-g', 2), mutate, true)
const answered = await hook().respondQuestion(
encodeAgentSessionQuestionAnswers([
{ questionId: 'q1', optionIds: ['q1:choice-1'] },
{ questionId: 'q2', optionIds: ['q2:choice-1'] }
]),
RENDERED_GROUP
)
expect(answered).toBe(false)
expect(sent).toEqual([])
})
it.each([true, false])('submits a grouped question once (answers: %s)', async (supported) => {
const prompt = groupedPrompt('item-g', 1)
const { mutate, sent } = recordingMutate()
mount(prompt, mutate, supported)
await act(async () => {
await hook().respondQuestion(projectedResponse(prompt, null))
})
await act(async () => {
await hook().respondQuestion(projectedResponse(prompt, hook().groupedDraft))
})
const answers = [
{ questionId: 'q1', optionIds: ['q1:choice-1'] },
{ questionId: 'q2', optionIds: ['q2:choice-1'] }
]
await act(async () => {
await hook().respondQuestion(encodeAgentSessionQuestionAnswers(answers), RENDERED_GROUP)
})
expect(sentFields(sent)).toEqual({
itemId: 'item-g',
expectedRevision: 1,
@@ -400,12 +327,12 @@ describe('useMobileStructuredPromptResponses', () => {
const onSendError = vi.fn()
mount(prompt, mutate, supported, onSendError)
const answers: AgentSessionQuestionAnswer[] = [
{ questionId: 'q1', optionIds: ['q1:choice-1'] },
{ questionId: 'q2', optionIds: [], other: LONG_ANSWER }
]
await act(async () => {
await hook().respondQuestion(projectedResponse(prompt, null))
})
const step = projectStructuredQuestion(prompt, hook().groupedDraft)!
await act(async () => {
await hook().respondQuestion(formatQuestionFreeTextAnswer(step, LONG_ANSWER))
await hook().respondQuestion(encodeAgentSessionQuestionAnswers(answers), RENDERED_GROUP)
})
if (!expectSent) {
@@ -415,70 +342,7 @@ describe('useMobileStructuredPromptResponses', () => {
)
return
}
expect(sentFields(sent)).toEqual({
itemId: 'item-g',
expectedRevision: 1,
answers: [
{ questionId: 'q1', optionIds: ['q1:choice-1'] },
{ questionId: 'q2', optionIds: [], other: LONG_ANSWER }
]
})
})
it('keeps a grouped draft an older host cannot take so it sends after an update', async () => {
const base = groupedPrompt('item-g', 1)
if (base.body.kind !== 'question' || !base.body.questions) {
throw new Error('expected a grouped question')
}
const [first, second] = base.body.questions
const prompt: StructuredQuestionItem = {
...base,
body: { ...base.body, questions: [{ ...first!, freeTextQuestionId: 'q1' }, second!] }
}
const { mutate, sent } = recordingMutate()
const onSendError = vi.fn()
mount(prompt, mutate, false, onSendError)
let advanced = false
await act(async () => {
advanced = await hook().respondQuestion(
formatQuestionFreeTextAnswer(projectStructuredQuestion(prompt, null)!, LONG_ANSWER)
)
})
expect(advanced).toBe(true)
await act(async () => {
await hook().respondQuestion(projectedResponse(prompt, hook().groupedDraft))
})
expect(sent).toEqual([])
expect(onSendError).toHaveBeenCalledWith(
'Update Orca on your computer to send answers this long'
)
expect(hook().groupedDraft).not.toBeNull()
act(() => {
renderer?.update(
createElement(Probe, {
sessionKey: 'session-a',
state: sessionState(prompt),
mutate,
questionAnswersSupported: true,
onSendError
})
)
})
await act(async () => {
await hook().respondQuestion(projectedResponse(prompt, hook().groupedDraft))
})
expect(sentFields(sent)).toEqual({
itemId: 'item-g',
expectedRevision: 1,
answers: [
{ questionId: 'q1', optionIds: [], other: LONG_ANSWER },
{ questionId: 'q2', optionIds: ['q2:choice-1'] }
]
})
expect(sentFields(sent)).toEqual({ itemId: 'item-g', expectedRevision: 1, answers })
})
it('names the single question an option tap answers when the prompt has no typed field', async () => {
@@ -1,8 +1,10 @@
import { useCallback, useState } from 'react'
import { useCallback } from 'react'
import type { AgentSessionPromptResult } from '../../../src/shared/agent-session-wire'
import type { AgentJournalQuestionItem } from '../../../src/shared/agent-session-journal-types'
import {
AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH,
decodeAgentSessionQuestionAnswers,
isValidAgentSessionQuestionAnswers,
legacyAgentSessionSelectedOptionId,
type AgentSessionQuestionAnswer
} from '../../../src/shared/agent-session-question-answer'
@@ -19,31 +21,19 @@ import type {
StructuredAgentSessionMutate,
StructuredAgentSessionMutationResult
} from './mobile-structured-agent-session-rpc'
import {
advanceGroupedQuestion,
groupedQuestionPromptKey,
type GroupedQuestionDraft
} from './mobile-structured-grouped-question'
import type { MobileChatQuestion } from './native-chat/input/mobile-native-chat-question'
export function useMobileStructuredPromptResponses(args: {
stateRef: { readonly current: StructuredAgentSessionState }
sessionKey: string
mutate: StructuredAgentSessionMutate
// Older hosts require answers packed into optionId.
questionAnswersSupported: boolean | null
onSendError: (message: string) => void
}): {
groupedDraft: GroupedQuestionDraft | null
respondPermission: (optionId: string) => Promise<boolean>
respondQuestion: (answer: string) => Promise<boolean>
respondQuestion: (answer: string, rendered?: MobileChatQuestion['prompt']) => Promise<boolean>
} {
const { mutate, onSendError, questionAnswersSupported, sessionKey, stateRef } = args
// Tag grouped drafts with the session so a switch cannot expose stale answers before an effect.
const [collected, setCollected] = useState<{
sessionKey: string
draft: GroupedQuestionDraft
} | null>(null)
const groupedDraft = collected?.sessionKey === sessionKey ? collected.draft : null
const { mutate, onSendError, questionAnswersSupported, stateRef } = args
const respondPermission = useCallback(
async (optionId: string): Promise<boolean> => {
@@ -101,39 +91,27 @@ export function useMobileStructuredPromptResponses(args: {
)
const respondQuestion = useCallback(
async (answer: string): Promise<boolean> => {
async (answer: string, rendered?: MobileChatQuestion['prompt']): Promise<boolean> => {
const prompt = actionableStructuredQuestion(stateRef.current)
if (prompt?.body.questions) {
const promptKey = groupedQuestionPromptKey(prompt.itemId, prompt.revision)
const grouped = advanceGroupedQuestion({
response: answer,
questions: prompt.body.questions,
draft: groupedDraft,
promptKey
})
if (!grouped) {
// Claude reuses question ids across prompts, so answers must match the prompt they were picked on.
const answers = decodeAgentSessionQuestionAnswers(answer)
if (
!answers ||
rendered?.itemId !== prompt.itemId ||
rendered.expectedRevision !== prompt.revision ||
!isValidAgentSessionQuestionAnswers(prompt.body.questions, answers)
) {
return false
}
if (grouped.kind === 'advance') {
setCollected({ sessionKey, draft: grouped.draft })
return true
}
const result = await sendAnswers(
{ itemId: prompt.itemId, expectedRevision: prompt.revision },
prompt.body,
grouped.answers
answers
)
if (!result) {
return false
}
if (result.status !== 'rejected') {
// Once submitted, retries must restart at the first question.
setCollected((current) =>
current?.sessionKey === sessionKey && current.draft.promptKey === promptKey
? null
: current
)
}
if (result.status === 'unknown') {
onSendError('Answer unconfirmed — check chat before retrying')
return false
@@ -158,8 +136,8 @@ export function useMobileStructuredPromptResponses(args: {
}
return result.status === 'accepted'
},
[groupedDraft, onSendError, sendAnswers, sessionKey, stateRef]
[onSendError, sendAnswers, stateRef]
)
return { groupedDraft, respondPermission, respondQuestion }
return { respondPermission, respondQuestion }
}
@@ -19,6 +19,7 @@ import {
ensureUnsupportedTerminalPromptReceipt,
observeReplayedTerminalPrompt
} from './terminal-prompt-receipt'
import { observeQuestionAnswerSend } from './terminal-send-question-answered'
export const TERMINAL_SEND_METHODS = [
defineMethod({
@@ -215,6 +216,7 @@ export const TERMINAL_SEND_METHODS = [
mobileFloorClaim.current = claim
}
: undefined
const answered = observeQuestionAnswerSend(params, runtime)
let result
let acceptedPromptCheckpoint: unknown
try {
@@ -301,7 +303,7 @@ export const TERMINAL_SEND_METHODS = [
)
}
// Deliberate mobile input claims its driver floor; clientless sends use the current mobile driver.
return { send: result }
return { send: answered(result) }
}
})
]
@@ -0,0 +1,58 @@
import type { z } from 'zod'
import { agentHookServer } from '../../../../agent-hooks/server'
import type { OrcaRuntimeService } from '../../../orca-runtime'
import type { TerminalSend } from '../../../../../shared/rpc-contract/terminal-unary-params'
import {
isPotentialQuestionAnsweredSubmitInput,
isQuestionAnsweredSubmitInput,
type AgentQuestionAnsweredInferenceRequest
} from '../../../../../shared/agent-question-answered-intent'
/** Snapshot before the write so a hook racing the answer wins over inference, as on desktop. */
function captureQuestionAnsweredBaseline(
input: string,
getPaneKey: () => string | null
): AgentQuestionAnsweredInferenceRequest | null {
// Reject ordinary input before resolving the pane or parsing prompt JSON.
if (!isPotentialQuestionAnsweredSubmitInput(input)) {
return null
}
const paneKey = getPaneKey()
if (!paneKey) {
return null
}
const [row] = agentHookServer.getStatusSnapshotForPane(paneKey)
if (!row || !isQuestionAnsweredSubmitInput(input, row.interactivePrompt)) {
return null
}
return {
paneKey,
baselineUpdatedAt: row.receivedAt,
baselineStateStartedAt: row.stateStartedAt,
baselinePrompt: row.prompt,
baselineAgentType: row.agentType
}
}
/** Claude sends no hook once a question is answered, so an accepted send of its submit key clears the wait. */
export function observeQuestionAnswerSend(
params: z.infer<typeof TerminalSend>,
runtime: Pick<OrcaRuntimeService, 'getLiveTerminalPaneKey'>
): <T extends { accepted?: boolean }>(result: T) => T {
// Desktop renderers infer answers from their own input; other writers rely on the host.
const baseline =
params.inputKind === 'query-reply' ||
params.interrupt === true ||
params.client?.type === 'desktop'
? null
: captureQuestionAnsweredBaseline(
`${params.text ?? ''}${params.enter === true ? '\r' : ''}`,
() => runtime.getLiveTerminalPaneKey(params.terminal)
)
return (result) => {
if (baseline && result.accepted === true) {
agentHookServer.inferQuestionAnswered(baseline)
}
return result
}
}
@@ -0,0 +1,68 @@
import './unused-default-rpc-methods.test-fixture'
import { afterEach, expect, it, vi } from 'vitest'
import { AgentHookServer, agentHookServer } from '../../agent-hooks/server'
import { makePaneKey } from '../../../shared/stable-pane-id'
import { RpcDispatcher } from './dispatcher'
import { TERMINAL_METHODS } from './methods/terminal'
import { stubRuntime } from './terminal-multiplex-test-harness'
const PANE_KEY = makePaneKey('tab-question', '11111111-1111-4111-8111-111111111111')
afterEach(() => {
vi.restoreAllMocks()
})
function claudeAsking(questions: unknown[]): AgentHookServer {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE_KEY,
tabId: 'tab-question',
worktreeId: 'worktree-question',
hookEventName: 'PreToolUse',
toolUseId: 'tool-question',
payload: {
state: 'waiting',
agentType: 'claude',
toolName: 'AskUserQuestion',
interactivePrompt: JSON.stringify({ questions })
}
},
'connection-1'
)
vi.spyOn(agentHookServer, 'getStatusSnapshotForPane').mockImplementation((paneKey) =>
server.getStatusSnapshotForPane(paneKey)
)
vi.spyOn(agentHookServer, 'inferQuestionAnswered').mockImplementation((request) =>
server.inferQuestionAnswered(request)
)
return server
}
async function phoneSends(text: string): Promise<void> {
const runtime = stubRuntime({
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
getLiveTerminalPaneKey: () => PANE_KEY,
sendTerminal: vi
.fn()
.mockResolvedValue({ handle: 'terminal-1', accepted: true, bytesWritten: 1 })
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const response = await dispatcher.dispatch({
id: 'req-1',
authToken: 'tok',
method: 'terminal.send',
params: { terminal: 'terminal-1', text, client: { id: 'phone', type: 'mobile' } }
})
expect(response.ok).toBe(true)
}
const option = (label: string) => ({ label, description: '' })
it('clears the Claude question wait when a phone keystroke finishes the answer', async () => {
const server = claudeAsking([{ question: 'Pick', options: [option('A'), option('B')] }])
await phoneSends('2')
expect(server.getStatusSnapshotForPane(PANE_KEY)[0]?.state).not.toBe('waiting')
})
@@ -11,6 +11,13 @@ import { useNativeChatQuestionAutoAdvance } from './use-native-chat-question-aut
import { useNativeChatQuestionKeys } from './use-native-chat-question-keys'
import { isEditableTarget } from '@/lib/editable-target'
import type { AgentJournalFreeTextInput } from '../../../../shared/agent-session-journal-types'
import {
askAcceptsEmptyAnswer,
askConfirmAction,
askConfirmLabel,
askTypedText,
toggleAskOption
} from '../../../../shared/native-chat-ask-card-rules'
export type NativeChatQuestionCardProps = {
prompt: AskPrompt
@@ -34,6 +41,12 @@ export type NativeChatQuestionCardProps = {
// Reserve a non-option index so single-select holds either an option or typed text.
const TYPED_ANSWER = -1
const CONFIRM_LABEL = {
submit: () => translate('components.native-chat.question.send', 'Submit'),
next: () => translate('components.native-chat.question.next', 'Next'),
skip: () => translate('components.native-chat.question.skip', 'Skip')
} as const
/** Single-select advances after a pick; multi-select confirms explicitly, and multi-question prompts retain earlier answers. */
export function NativeChatQuestionCard({
prompt,
@@ -67,9 +80,9 @@ export function NativeChatQuestionCard({
freeTextInput?.placeholder ??
translate('components.native-chat.question.otherPlaceholder', 'Type your answer')
const acceptsEmpty = (qi: number): boolean =>
freeTextInputs?.[qi]?.allowEmpty === true && prompt.questions[qi]?.options.length === 0
askAcceptsEmptyAnswer(prompt.questions[qi], freeTextInputs?.[qi])
const typedText = (qi: number, oth = otherText): string =>
freeTextInputs?.[qi]?.allowEmpty ? (oth[qi] ?? '') : (oth[qi] ?? '').trim()
askTypedText(oth[qi] ?? '', freeTextInputs?.[qi])
// Picking an option deselects typed text without erasing it from the field.
const typedAnswerChosen = (qi: number, sel = selections, oth = otherText): boolean =>
@@ -141,11 +154,7 @@ export function NativeChatQuestionCard({
const pickOption = (optionIndex: number): void => {
const cur = selections[index] ?? []
const picked = !cur.includes(optionIndex)
const chosen = !picked
? cur.filter((pickedIndex) => pickedIndex !== optionIndex)
: q.multiSelect
? [...cur, optionIndex].sort((a, b) => a - b)
: [optionIndex]
const chosen = toggleAskOption(cur, optionIndex, q.multiSelect)
const next = selections.map((s, i) => (i === index ? chosen : s))
setSelections(next)
autoAdvance.cancel()
@@ -155,17 +164,13 @@ export function NativeChatQuestionCard({
}
useNativeChatQuestionKeys(cardRef, shouldFocus && !isSubmitting, q.options.length, pickOption)
// Only the final question submits; an empty final Skip dismisses, while empty Enter preserves the prompt.
const confirm = (fromKeyboard = false): void => {
autoAdvance.cancel()
if (!isLast) {
advanceOrSubmit(selections, otherText)
return
}
const anyAnswered = prompt.questions.some((_, i) => answerFor(i).length > 0 || acceptsEmpty(i))
if (anyAnswered) {
submitAll(selections, otherText)
} else if (!fromKeyboard) {
const action = askConfirmAction({ isLast, anyAnswered, fromKeyboard })
if (action === 'proceed') {
advanceOrSubmit(selections, otherText)
} else if (action === 'cancel') {
onCancel()
}
}
@@ -353,11 +358,7 @@ export function NativeChatQuestionCard({
>
{isSubmitting
? translate('components.native-chat.question.sending', 'Sending…')
: currentAnswered
? isLast
? translate('components.native-chat.question.send', 'Submit')
: translate('components.native-chat.question.next', 'Next')
: translate('components.native-chat.question.skip', 'Skip')}
: CONFIRM_LABEL[askConfirmLabel(currentAnswered, isLast)]()}
</button>
</div>
</div>
@@ -1,6 +1,10 @@
import type { RefObject } from 'react'
import type { AgentJournalQuestion } from '../../../../shared/agent-session-journal-types'
import type { AgentSessionPromptResponse } from '../../../../shared/agent-session-question-answer'
import {
structuredQuestionAnswers,
structuredQuestionCard
} from '../../../../shared/structured-agent-question-card'
import { NativeChatQuestionCard } from './NativeChatQuestionCard'
export function NativeChatStructuredQuestionCard({
@@ -18,38 +22,16 @@ export function NativeChatStructuredQuestionCard({
shouldFocus?: boolean
answerInputRef?: RefObject<HTMLInputElement | null>
}): React.JSX.Element {
const card = structuredQuestionCard(questions)
return (
<NativeChatQuestionCard
prompt={{
questions: questions.map((question) => ({
question: question.question,
...(question.header ? { header: question.header } : {}),
multiSelect: question.multiSelect,
options: question.options.map((option) => ({
label: option.label,
...(option.description ? { description: option.description } : {})
}))
}))
}}
allowOther={questions.map((question) => Boolean(question.freeTextQuestionId))}
freeTextInputs={questions.map((question) => question.freeTextInput)}
onAnswer={(answers) => {
const chosen = questions.map((question, index) => {
const answer = answers[index]
const other = question.freeTextInput?.allowEmpty ? answer?.other : answer?.other?.trim()
return {
questionId: question.id,
optionIds: (answer?.indices ?? []).flatMap((optionIndex) => {
const id = question.options[optionIndex]?.id
return id ? [id] : []
}),
...(other !== undefined && (other.length > 0 || question.freeTextInput?.allowEmpty)
? { other }
: {})
}
})
if (chosen.every((answer) => answer.optionIds.length > 0 || answer.other !== undefined)) {
onAnswer({ kind: 'answers', answers: chosen })
prompt={card.prompt}
allowOther={card.allowOther}
freeTextInputs={card.freeTextInputs}
onAnswer={(selections) => {
const answers = structuredQuestionAnswers(questions, selections)
if (answers) {
onAnswer({ kind: 'answers', answers })
}
}}
onCancel={onCancel}
+51
View File
@@ -0,0 +1,51 @@
import type { AgentJournalFreeTextInput } from './agent-session-journal-types'
import type { AskQuestion } from './native-chat-ask-types'
// How a stepped question card picks, labels and confirms; desktop and mobile draw it differently.
/** Multi-select keeps picks in option order so the answer reads as the options do. */
export function toggleAskOption(
picked: readonly number[],
optionIndex: number,
multiSelect: boolean
): number[] {
if (picked.includes(optionIndex)) {
return picked.filter((index) => index !== optionIndex)
}
return multiSelect ? [...picked, optionIndex].sort((a, b) => a - b) : [optionIndex]
}
/** Free text is kept verbatim only where the provider accepts an empty or spaced answer. */
export function askTypedText(raw: string, input: AgentJournalFreeTextInput | undefined): string {
return input?.allowEmpty ? raw : raw.trim()
}
/** A typing-only question whose provider takes an empty answer counts as answered. */
export function askAcceptsEmptyAnswer(
question: AskQuestion | undefined,
input: AgentJournalFreeTextInput | undefined
): boolean {
return input?.allowEmpty === true && question?.options.length === 0
}
/** Proceed advances, or submits on the last question; an empty final Skip dismisses, while empty Enter keeps the prompt. */
export function askConfirmAction(args: {
isLast: boolean
anyAnswered: boolean
fromKeyboard?: boolean
}): 'proceed' | 'cancel' | 'stay' {
if (!args.isLast || args.anyAnswered) {
return 'proceed'
}
return args.fromKeyboard ? 'stay' : 'cancel'
}
export function askConfirmLabel(
currentAnswered: boolean,
isLast: boolean
): 'submit' | 'next' | 'skip' {
if (!currentAnswered) {
return 'skip'
}
return isLast ? 'submit' : 'next'
}
@@ -0,0 +1,59 @@
import type { AgentJournalFreeTextInput, AgentJournalQuestion } from './agent-session-journal-types'
import type { AgentSessionQuestionAnswer } from './agent-session-question-answer'
import type { AskAnswerSelection } from './native-chat-ask-answer'
import type { AskPrompt } from './native-chat-ask-types'
import { askTypedText } from './native-chat-ask-card-rules'
/** A structured prompt drawn with the same stepped card as a terminal ask. */
type StructuredQuestionCard = {
prompt: AskPrompt
allowOther: boolean[]
freeTextInputs: (AgentJournalFreeTextInput | undefined)[]
}
export function structuredQuestionCard(
questions: readonly AgentJournalQuestion[]
): StructuredQuestionCard {
return {
prompt: {
questions: questions.map((question) => ({
question: question.question,
...(question.header ? { header: question.header } : {}),
multiSelect: question.multiSelect,
options: question.options.map((option) => ({
label: option.label,
...(option.description ? { description: option.description } : {})
}))
}))
},
allowOther: questions.map((question) => Boolean(question.freeTextQuestionId)),
freeTextInputs: questions.map((question) => question.freeTextInput)
}
}
/** Null until every question has an answer, because hosts refuse a partial set. */
export function structuredQuestionAnswers(
questions: readonly AgentJournalQuestion[],
selections: readonly AskAnswerSelection[]
): AgentSessionQuestionAnswer[] | null {
const answers = questions.map((question, index) => {
const selection = selections[index]
const other =
selection?.other === undefined
? undefined
: askTypedText(selection.other, question.freeTextInput)
return {
questionId: question.id,
optionIds: (selection?.indices ?? []).flatMap((optionIndex) => {
const id = question.options[optionIndex]?.id
return id ? [id] : []
}),
...(other !== undefined && (other.length > 0 || question.freeTextInput?.allowEmpty)
? { other }
: {})
}
})
return answers.every((answer) => answer.optionIds.length > 0 || answer.other !== undefined)
? answers
: null
}