Files
orca/mobile/src/session/MobileNativeChatQuestion.tsx
T
Brennan Benson 28d07a6b63 fix(mobile): open Grok, OpenCode, OMP and Pi chats on the phone (#27212)
* fix(mobile): open Grok, OpenCode, OMP and Pi chats on the phone

The phone advertised only the Claude chat lane, so the host retitled every
other agent's chat tab 'Update to view' and the phone's resolver refused
non-Claude/Codex chat tabs anyway. Mobile now reads any agent the host
lists, passes the chat's real agent to its send and slash-command paths,
advertises the registered-agents and Pi dialog capabilities, hides image
attach for agents the host says take none (as desktop does), and draws
Pi's input dialog as one line.

* test(mobile): drive the agents read through the shared fake client

* fix(mobile): read options reports with no model

Grok, OpenCode and OMP can report no current model, and a chat at rest may
have none saved. The host refuses those reports to clients that don't
advertise agent-session.options.optional-model.v1, which left the phone's
model picker on its seed list and dropped /clear and /compact.

* fix(mobile): reserve the chat session for every launched agent

The phone matches a launched chat tab only by its session id, which it
minted only for Claude and Codex. A Grok, OpenCode, OMP or Pi chat started
from + landed only once the host replied, and a lost reply read as
"Couldn't confirm the agent started" even with the chat listed. Hosts
accept an agent-named id for any agent and ignore it for a terminal launch.

* fix(mobile): read the host's agents once per connection, retry after a failure

Every session-screen mount asked agentSession.agents again, so the attach
button popped in late for OpenCode and Pi, and a read that failed while
connected was never retried. Share one read per client, drop it on
disconnect, and forget a failed read so the next chat asks again.

* test(mobile): pin the chat's own agent on the send path and attach gating

The send-bridge test passed with the claude/codex narrowing reverted. Drive
an OpenCode chat through the controller and the structured session's send,
where /review is the agent's own command but Codex's host claims it, and
check the overlay offers attach only when the agent takes images.

* fix(mobile): offer a card's X only while a turn is running

The X on a structured prompt card stops its turn. Pi opens dialogs outside
a turn too, and tapping X there could only say "Stop not sent". Claude and
Codex cards arrive inside a running turn, so they keep their X.

* fix(runtime): let a phone read the host's registered agents

The phone now asks agentSession.agents to decide whether a chat's agent
takes images, but the mobile allowlist refused the method, so OpenCode and
Pi never got an attach button. It returns only each agent's id and its
fixed capability record.

* fix(mobile): keep a host's refusal of the agents list for the connection

Released hosts advertise registered agents but refuse agentSession.agents
to phones (forbidden, not available to mobile clients). Treat that answer
as no list until the next connection instead of re-asking on every chat;
keep retrying transient failures.

* fix(mobile): shrink the model pill before it pushes Send off the composer row

On a narrow phone, attach + a long model label + mic left no room for Send
(seen with Pi's resting label). The pill's slot is now the row's only
shrinkable item, so its label truncates and the buttons keep their size.
Rows that already fit look the same.

* fix(mobile): keep /clear and /compact for a chat whose agent lists no models

The phone dropped a structured chat's option controller when its picker had
nothing to show, and the / menu reads the chat's conversation commands from
that controller. OMP lists no models, so its menu lost /clear and /compact.
Keep the controller for every structured chat; the picker still renders
nothing without a model.

* feat(runtime): say in a chat's options report whether it takes images

The host's options read now carries acceptsImages for every session: the
agent definition's imagePrompts, narrowed by a running ACP child to what
its initialize advertised (the same rule its sends use). Absent means an
older host.

* refactor(mobile): read whether a chat takes images from its options report

The phone no longer reads the host's agent list for the attach button. It
takes acceptsImages from the agentSession.options read it already makes,
keyed to the chat, and kept through a pick made during that read. Before
the read answers, and from an older host, Claude and Codex keep attach and
other agents don't, as before. Drops the agent-list hook with its cache and
refusal latch, the host-support flag only it used, and agentSession.agents
from the host's mobile allowlist.

* refactor(native-chat): gate desktop chat images on the session's options report

The composer's image gate takes the session's acceptsImages answer first,
falling back to the host's agent list before the options read lands (a
new chat's start) and for an older host that sends no answer. With the
phone no longer using it, structuredAgentAcceptsImages moves back to the
renderer, where it was.

* test(native-chat): prove the session's image answer reaches the composer gate
2026-10-11 13:00:56 -07:00

320 lines
9.9 KiB
TypeScript

import { useMemo, useRef, useState } from 'react'
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
import { ArrowUp, Check, CircleHelp } from 'lucide-react-native'
import { MobileNativeChatCardHeaderAction } from './MobileNativeChatCardHeaderAction'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { mobileNativeChatInputStyles } from './mobile-native-chat-input-styles'
import {
formatQuestionAnswerByIndexes,
formatQuestionAnswerWithOtherByIndexes,
formatQuestionFreeTextAnswer,
type MobileChatQuestion
} from './mobile-native-chat-question'
type Props = {
question: MobileChatQuestion
onAnswer: (text: string) => Promise<boolean>
onCancel?: (prompt?: NonNullable<MobileChatQuestion['prompt']>) => Promise<boolean>
/** Fold the card to a strip and free Send, writing nothing. */
onCollapse?: () => void
}
/** Renders an agent's choice prompt as a tappable card. Single-select answers
* on tap; multi-select toggles then Submits; an always-present text entry lets
* the user answer freely (the escape hatch) when the heuristic misreads the
* options or none apply. */
export function MobileNativeChatQuestion({
question,
onAnswer,
onCancel,
onCollapse
}: Props): React.JSX.Element {
const [selectedOptionIndexes, setSelectedOptionIndexes] = useState<number[]>([])
const [freeText, setFreeText] = useState(() => question.freeTextInput?.initialValue ?? '')
const [sending, setSending] = useState(false)
const sendingRef = useRef(false)
const allowOther = question.allowOther !== false
const hasOptions = question.options.length > 0
const answerText = question.freeTextInput?.allowEmpty ? freeText : freeText.trim()
const toggle = (optionIndex: number): void => {
setSelectedOptionIndexes((prev) =>
prev.includes(optionIndex)
? prev.filter((index) => index !== optionIndex)
: [...prev, optionIndex]
)
}
const sendAnswer = async (text: string): Promise<boolean> => {
if (sendingRef.current) {
return false
}
sendingRef.current = true
setSending(true)
try {
return await onAnswer(text)
} finally {
sendingRef.current = false
setSending(false)
}
}
const answerSingle = async (optionIndex: number): Promise<void> => {
const token = question.optionTokens[optionIndex]
await sendAnswer(
token && token.length > 0 ? token : formatQuestionAnswerByIndexes(question, [optionIndex])
)
}
const submitMulti = async (): Promise<void> => {
if (selectedOptionIndexes.length === 0) {
return
}
const answer =
question.freeTextToken && answerText.length > 0
? formatQuestionAnswerWithOtherByIndexes(question, selectedOptionIndexes, answerText)
: formatQuestionAnswerByIndexes(question, selectedOptionIndexes)
if (await sendAnswer(answer)) {
setFreeText('')
}
}
const submitFreeText = async (): Promise<void> => {
if (!allowOther || (answerText.length === 0 && !question.freeTextInput?.allowEmpty)) {
return
}
const answer =
question.multiSelect && question.freeTextToken && selectedOptionIndexes.length > 0
? formatQuestionAnswerWithOtherByIndexes(question, selectedOptionIndexes, answerText)
: formatQuestionFreeTextAnswer(question, answerText)
if (await sendAnswer(answer)) {
setFreeText('')
}
}
const canSubmitMulti = selectedOptionIndexes.length > 0 && !sending
const canSendFreeText =
allowOther && (answerText.length > 0 || question.freeTextInput?.allowEmpty === true) && !sending
// Stable keys for option rows even if an agent repeats a label.
const optionRows = useMemo(
() =>
question.options.map((label, index) => ({
label,
description: question.optionDescriptions?.[index],
key: `${index}:${label}`
})),
[question.optionDescriptions, question.options]
)
return (
<View style={styles.card}>
<View style={styles.header}>
<CircleHelp size={15} color={colors.accentBlue} strokeWidth={2.2} />
<Text style={styles.question}>{question.question}</Text>
<MobileNativeChatCardHeaderAction
prompt={question.prompt}
onCancel={onCancel}
onCollapse={onCollapse}
disabled={sending}
/>
</View>
{hasOptions ? (
<View style={styles.options}>
{optionRows.map(({ label, description, key }, optIndex) => {
const isSelected = selectedOptionIndexes.includes(optIndex)
return (
<Pressable
key={key}
accessibilityRole={question.multiSelect ? 'checkbox' : 'button'}
accessibilityState={question.multiSelect ? { checked: isSelected } : undefined}
style={({ pressed }) => [
styles.option,
isSelected && styles.optionSelected,
pressed && styles.pressed
]}
onPress={() => (question.multiSelect ? toggle(optIndex) : answerSingle(optIndex))}
>
{question.multiSelect ? (
<View style={[styles.checkbox, isSelected && styles.checkboxOn]}>
{isSelected ? <Check size={13} color={colors.bgBase} strokeWidth={3} /> : null}
</View>
) : null}
<View style={styles.optionBody}>
<Text style={styles.optionText}>{label}</Text>
{description ? (
<Text style={styles.optionDescription} numberOfLines={2}>
{description}
</Text>
) : null}
</View>
</Pressable>
)
})}
</View>
) : null}
{question.multiSelect && hasOptions ? (
<Pressable
accessibilityLabel="Submit selected options"
style={({ pressed }) => [
styles.submit,
!canSubmitMulti && styles.submitDisabled,
pressed && canSubmitMulti && styles.pressed
]}
onPress={submitMulti}
disabled={!canSubmitMulti}
>
<Text style={[styles.submitText, !canSubmitMulti && styles.submitTextDisabled]}>
Submit{selectedOptionIndexes.length > 0 ? ` (${selectedOptionIndexes.length})` : ''}
</Text>
</Pressable>
) : null}
{allowOther ? (
<View style={styles.freeTextRow}>
<TextInput
style={mobileNativeChatInputStyles.freeInput}
value={freeText}
onChangeText={setFreeText}
placeholder={
question.freeTextInput?.placeholder ??
(hasOptions ? 'Or type a reply…' : 'Type your reply…')
}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
onSubmitEditing={submitFreeText}
returnKeyType="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}
/>
<Pressable
accessibilityLabel="Send reply"
style={({ pressed }) => [
styles.freeSend,
!canSendFreeText && styles.freeSendDisabled,
pressed && canSendFreeText && styles.pressed
]}
onPress={submitFreeText}
disabled={!canSendFreeText}
>
<ArrowUp
size={18}
color={canSendFreeText ? colors.bgBase : colors.textMuted}
strokeWidth={2.6}
/>
</Pressable>
</View>
) : null}
</View>
)
}
const styles = StyleSheet.create({
card: {
marginHorizontal: spacing.lg,
marginVertical: spacing.sm,
padding: spacing.md,
gap: spacing.sm,
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
question: {
flex: 1,
color: colors.textPrimary,
fontSize: typography.bodySize + 1,
fontWeight: '600',
lineHeight: typography.bodySize + 7
},
options: {
gap: spacing.xs
},
option: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
minHeight: 44,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: colors.bgRaised,
borderRadius: radii.button,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle
},
optionSelected: {
borderColor: colors.accentBlue
},
optionBody: {
flex: 1,
gap: 2
},
optionText: {
color: colors.textPrimary,
fontSize: typography.bodySize + 1
},
optionDescription: {
color: colors.textMuted,
fontSize: typography.metaSize,
lineHeight: typography.metaSize + 5
},
checkbox: {
width: 20,
height: 20,
borderRadius: radii.button,
borderWidth: 1.5,
borderColor: colors.textMuted,
alignItems: 'center',
justifyContent: 'center'
},
checkboxOn: {
backgroundColor: colors.accentBlue,
borderColor: colors.accentBlue
},
submit: {
minHeight: 44,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
backgroundColor: colors.accentBlue
},
submitDisabled: {
backgroundColor: colors.bgRaised
},
submitText: {
color: colors.onMergeGreen,
fontSize: typography.bodySize + 1,
fontWeight: '600'
},
submitTextDisabled: {
color: colors.textMuted
},
freeTextRow: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: spacing.sm
},
freeSend: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.textPrimary
},
freeSendDisabled: {
backgroundColor: colors.bgRaised
},
pressed: {
opacity: 0.7
}
})