Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/site/content/docs/agents/native-chat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ Toggle Chat UI ↔ terminal from the agent pane once enabled.

When Claude shows an **AskUserQuestion** (or similar structured permission/question card), Chat UI should render the question card in the transcript so you can answer from the composer path instead of treating it as a collapsed tool call. Confirm with **Submit** on the card. That includes agents running on a paired [Remote Orca Server](/docs/remote-servers) / headless host — not only local panes.

OMP ACP forms use the same desktop and mobile cards for choices, text, and confirmations. Optional answers can be left unset; choosing an empty value or **No selections** is distinct from leaving a choice unset. **Decline request** replies to the form; **Cancel** dismisses the prompt and stops the turn. On desktop, **Skip** advances past an unanswered question without discarding other answers. On the final question it attempts submission if any answers exist, but all form questions must have an answer before submission; choose **Leave unset** for an optional choice. If no answers exist, clicking **Skip** dismisses the prompt and stops the turn. Full access does not answer these human decisions automatically.

An offered empty-string choice without a label appears as **Empty value**; selecting it still sends an empty string, not an omitted answer.

Stopping the turn withdraws its open cards and rejects late answers. OMP versions whose ACP transport does not send a form-cancellation notification cannot notify Orca of their own dialog timeout; dismiss the remaining card or stop the turn.

## Availability

Chat UI ships on desktop for supported local and remote (paired server) agent sessions. The [mobile companion](/docs/mobile) reuses chat-style transcript patterns for the same paired sessions.
Expand Down
51 changes: 45 additions & 6 deletions mobile/src/session/MobileNativeChatQuestion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,20 @@ export function MobileNativeChatQuestion({

const hasOptions = question.options.length > 0
const answerText = question.freeTextInput?.allowEmpty ? freeText : freeText.trim()
const hasExclusiveSelection = selectedOptionIndexes.some(
(index) => question.optionExclusive?.[index]
)

const toggle = (optionIndex: number): void => {
if (question.optionExclusive?.[optionIndex]) {
setFreeText('')
}
setSelectedOptionIndexes((prev) =>
prev.includes(optionIndex)
? prev.filter((index) => index !== optionIndex)
: [...prev, optionIndex]
: question.optionExclusive?.[optionIndex]
? [optionIndex]
: [...prev.filter((index) => !question.optionExclusive?.[index]), optionIndex]
)
}

Expand Down Expand Up @@ -72,7 +80,7 @@ export function MobileNativeChatQuestion({
return
}
const answer =
question.freeTextToken && answerText.length > 0
question.freeTextToken && answerText.length > 0 && !hasExclusiveSelection
? formatQuestionAnswerWithOtherByIndexes(question, selectedOptionIndexes, answerText)
: formatQuestionAnswerByIndexes(question, selectedOptionIndexes)
if (await sendAnswer(answer)) {
Expand All @@ -86,7 +94,11 @@ export function MobileNativeChatQuestion({
}
const answer =
question.multiSelect && question.freeTextToken && selectedOptionIndexes.length > 0
? formatQuestionAnswerWithOtherByIndexes(question, selectedOptionIndexes, answerText)
? formatQuestionAnswerWithOtherByIndexes(
question,
selectedOptionIndexes.filter((index) => !question.optionExclusive?.[index]),
answerText
)
: formatQuestionFreeTextAnswer(question, answerText)
if (await sendAnswer(answer)) {
setFreeText('')
Expand Down Expand Up @@ -173,20 +185,41 @@ export function MobileNativeChatQuestion({
</Pressable>
) : null}

{question.declineToken ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Decline request"
style={({ pressed }) => [
styles.submit,
styles.decline,
pressed && !sending && styles.pressed
]}
disabled={sending}
onPress={() => sendAnswer(question.declineToken!)}
>
<Text style={[styles.submitText, styles.declineText]}>Decline request</Text>
</Pressable>
) : null}

{allowOther ? (
<View style={styles.freeTextRow}>
<TextInput
style={mobileNativeChatInputStyles.freeInput}
value={freeText}
onChangeText={setFreeText}
onChangeText={(text) => {
setFreeText(text)
setSelectedOptionIndexes((prev) =>
prev.filter((index) => !question.optionExclusive?.[index])
)
}}
placeholder={
question.freeTextInput?.placeholder ??
(hasOptions ? 'Or type a reply…' : 'Type your reply…')
}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
onSubmitEditing={submitFreeText}
returnKeyType="send"
onSubmitEditing={question.freeTextInput?.multiline ? undefined : submitFreeText}
returnKeyType={question.freeTextInput?.multiline ? 'default' : 'send'}
// A declared input (Pi's `input`) is one line unless it asks for more, as on desktop.
multiline={question.freeTextInput ? question.freeTextInput.multiline === true : true}
/>
Expand Down Expand Up @@ -297,6 +330,12 @@ const styles = StyleSheet.create({
submitTextDisabled: {
color: colors.textMuted
},
decline: {
backgroundColor: colors.bgRaised
},
declineText: {
color: colors.textMuted
},
freeTextRow: {
flexDirection: 'row',
alignItems: 'flex-end',
Expand Down
8 changes: 5 additions & 3 deletions mobile/src/session/MobileNativeChatResolutionReceipt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,11 @@ export function MobileNativeChatResolutionReceipt({
>
{heading ? <Text style={text}>{heading}</Text> : null}
<Text selectable={INLINE_TEXT_SELECTION} style={text} numberOfLines={3}>
{answer.answer === ''
? NATIVE_CHAT_RECEIPT_COPY.emptyAnswer
: (answer.answer ?? NATIVE_CHAT_RECEIPT_COPY.unavailable)}
{answer.copy === 'declined'
? NATIVE_CHAT_RECEIPT_COPY.declined
: answer.answer === ''
? NATIVE_CHAT_RECEIPT_COPY.emptyAnswer
: (answer.answer ?? NATIVE_CHAT_RECEIPT_COPY.unavailable)}
</Text>
</View>
)
Expand Down
3 changes: 3 additions & 0 deletions mobile/src/session/mobile-native-chat-question.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export type MobileChatQuestion = {
optionTokens: (string | null)[]
/** Per-option secondary text from structured prompts, parallel to `options`. */
optionDescriptions?: (string | undefined)[]
optionExclusive?: boolean[]
/** Opaque whole-form refusal response, distinct from cancellation. */
declineToken?: string
/** Opaque prefix used when free-text answers must target a specific prompt. */
freeTextToken?: string
freeTextInput?: AgentJournalFreeTextInput
Expand Down
108 changes: 43 additions & 65 deletions mobile/src/session/mobile-structured-agent-prompts.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { z } from 'zod'
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
import {
agentSessionPromptQuestions,
Expand Down Expand Up @@ -31,26 +32,26 @@ export type StructuredQuestionResponseTarget = {
answer: AgentSessionQuestionAnswer
}

type PromptTokenPayload =
| {
kind: 'approval'
itemId: string
revision: number
optionId: string
}
| {
kind: 'question-option'
itemId: string
revision: number
questionId: string
optionId: string
}
| {
kind: 'question-free-text'
itemId: string
revision: number
questionId: string
}
const promptIdentityFields = { itemId: z.string(), revision: z.number().finite() }
const promptTokenSchema = z.discriminatedUnion('kind', [
z.object({
...promptIdentityFields,
kind: z.enum(['approval', 'question-decline']),
optionId: z.string()
}),
z.object({
...promptIdentityFields,
kind: z.literal('question-option'),
questionId: z.string(),
optionId: z.string()
}),
z.object({
...promptIdentityFields,
kind: z.literal('question-free-text'),
questionId: z.string()
})
])
type PromptTokenPayload = z.infer<typeof promptTokenSchema>

const STRUCTURED_PROMPT_TOKEN_PREFIX = 'structured-agent-prompt:'

Expand All @@ -70,54 +71,18 @@ function encodePromptToken(payload: PromptTokenPayload): string {
return `${STRUCTURED_PROMPT_TOKEN_PREFIX}${encodeURIComponent(JSON.stringify(payload))}`
}

function decodePromptToken(value: string): PromptTokenPayload | null {
export function decodeStructuredPromptToken(value: string): PromptTokenPayload | null {
if (!value.startsWith(STRUCTURED_PROMPT_TOKEN_PREFIX)) {
return null
}
try {
const decoded = JSON.parse(
decodeURIComponent(value.slice(STRUCTURED_PROMPT_TOKEN_PREFIX.length))
) as Record<string, unknown>
if (
typeof decoded.itemId !== 'string' ||
typeof decoded.revision !== 'number' ||
!Number.isFinite(decoded.revision)
) {
return null
}
if (decoded.kind === 'approval' && typeof decoded.optionId === 'string') {
return {
kind: decoded.kind,
itemId: decoded.itemId,
revision: decoded.revision,
optionId: decoded.optionId
}
}
if (
decoded.kind === 'question-option' &&
typeof decoded.questionId === 'string' &&
typeof decoded.optionId === 'string'
) {
return {
kind: decoded.kind,
itemId: decoded.itemId,
revision: decoded.revision,
questionId: decoded.questionId,
optionId: decoded.optionId
}
}
if (decoded.kind === 'question-free-text' && typeof decoded.questionId === 'string') {
return {
kind: decoded.kind,
itemId: decoded.itemId,
revision: decoded.revision,
questionId: decoded.questionId
}
}
const parsed = promptTokenSchema.safeParse(
JSON.parse(decodeURIComponent(value.slice(STRUCTURED_PROMPT_TOKEN_PREFIX.length)))
)
return parsed.success ? parsed.data : null
} catch {
return null
}
return null
}

function decodeQuestionFreeTextAnswer(value: string): {
Expand All @@ -131,7 +96,7 @@ function decodeQuestionFreeTextAnswer(value: string): {
if (separator === -1) {
return null
}
const payload = decodePromptToken(value.slice(0, separator))
const payload = decodeStructuredPromptToken(value.slice(0, separator))
if (payload?.kind !== 'question-free-text') {
return null
}
Expand Down Expand Up @@ -172,13 +137,22 @@ export function projectStructuredQuestion(
if (prompt?.body.kind !== 'question') {
return null
}
const declineToken = prompt.body.declineOptionId
? encodePromptToken({
kind: 'question-decline',
itemId: prompt.itemId,
revision: prompt.revision,
optionId: prompt.body.declineOptionId
})
: undefined
if (prompt.body.questions) {
return projectGroupedQuestion(
const projected = projectGroupedQuestion(
prompt.body.questions,
groupedDraft,
groupedQuestionPromptKey(prompt.itemId, prompt.revision),
{ itemId: prompt.itemId, expectedRevision: prompt.revision }
)
return projected && declineToken ? { ...projected, declineToken } : projected
}
const [question] = agentSessionPromptQuestions(prompt.body)
if (!question) {
Expand All @@ -190,6 +164,10 @@ export function projectStructuredQuestion(
prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision },
options: prompt.body.options.map((option) => option.label),
...(optionDescriptions.some(Boolean) ? { optionDescriptions } : {}),
...(declineToken ? { declineToken } : {}),
...(prompt.body.options.some((option) => option.exclusive)
? { optionExclusive: prompt.body.options.map((option) => option.exclusive === true) }
: {}),
multiSelect: false,
allowOther: Boolean(prompt.body.freeTextQuestionId),
...(prompt.body.freeTextInput ? { freeTextInput: prompt.body.freeTextInput } : {}),
Expand Down Expand Up @@ -219,7 +197,7 @@ export function structuredApprovalResponseTarget(
response: string,
currentPrompt: StructuredApprovalItem | null
): StructuredPromptResponseTarget | null {
const token = decodePromptToken(response)
const token = decodeStructuredPromptToken(response)
if (token?.kind === 'approval') {
return {
itemId: token.itemId,
Expand All @@ -246,7 +224,7 @@ export function structuredQuestionResponseTarget(
response: string,
currentPrompt: StructuredQuestionItem | null
): StructuredQuestionResponseTarget | null {
const token = decodePromptToken(response)
const token = decodeStructuredPromptToken(response)
if (token?.kind === 'question-option') {
return {
itemId: token.itemId,
Expand Down
5 changes: 4 additions & 1 deletion mobile/src/session/mobile-structured-grouped-question.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ export function projectGroupedQuestion(
...(promptIdentity ? { prompt: promptIdentity } : {}),
options: question.options.map((option) => option.label),
...(optionDescriptions.some(Boolean) ? { optionDescriptions } : {}),
...(question.options.some((option) => option.exclusive)
? { optionExclusive: question.options.map((option) => option.exclusive === true) }
: {}),
multiSelect: question.multiSelect,
allowOther: Boolean(question.freeTextQuestionId),
...(question.freeTextInput ? { freeTextInput: question.freeTextInput } : {}),
Expand Down Expand Up @@ -210,7 +213,7 @@ export function advanceGroupedQuestion(args: {
return null
}
const answer = answerFromResponse(args.response, question, args.promptKey)
if (!answer) {
if (!answer || !isValidAgentSessionQuestionAnswers([question], [answer])) {
return null
}
const answers = [...collected, answer]
Expand Down
Loading
Loading