mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user