mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 16:02:41 +00:00
* fix(native-chat): hide activity while awaiting input * fix(native-chat): keep approval turns cancellable * test(native-chat): satisfy split PR quality gate * fix(native-chat): catalog approval cancellation label * fix(native-chat): include approval cancellation runtime label * fix(codex): settle prompts when cancelled turns complete * fix(codex): settle prompt registry fallbacks * test(native-chat): cover pending interaction fallbacks * test(native-chat): split prompt state coverage * test(native-chat): keep prompt state isolated * fix(native-chat): bound prompt turn backfill * refactor(codex): centralize prompt registry bounds * fix(native-chat): cancel pending prompts precisely * fix(native-chat): consolidate capability imports * fix(native-chat): harden precise prompt cancellation * fix claude cancellation teardown races * retry claude prompt lifecycle admission * bound claude prompt cancellation retry work * fix(codex): bound prompt turn identity on registration * fix(native-chat): route rejected late dispatch settlements * fix(codex): retain exact cancellable prompt turn ids --------- Co-authored-by: Merge Sim <sim@local>
333 lines
9.7 KiB
TypeScript
333 lines
9.7 KiB
TypeScript
import { useMemo, useRef, useState } from 'react'
|
|
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
|
|
import { ArrowUp, Check, CircleHelp, X } from 'lucide-react-native'
|
|
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
|
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>
|
|
}
|
|
|
|
/** 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
|
|
}: Props): React.JSX.Element {
|
|
const [selectedOptionIndexes, setSelectedOptionIndexes] = useState<number[]>([])
|
|
const [freeText, setFreeText] = useState('')
|
|
const [sending, setSending] = useState(false)
|
|
const sendingRef = useRef(false)
|
|
const allowOther = question.allowOther !== false
|
|
|
|
const hasOptions = question.options.length > 0
|
|
const trimmedFreeText = 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 && trimmedFreeText.length > 0
|
|
? formatQuestionAnswerWithOtherByIndexes(question, selectedOptionIndexes, trimmedFreeText)
|
|
: formatQuestionAnswerByIndexes(question, selectedOptionIndexes)
|
|
if (await sendAnswer(answer)) {
|
|
setFreeText('')
|
|
}
|
|
}
|
|
|
|
const submitFreeText = async (): Promise<void> => {
|
|
if (trimmedFreeText.length === 0) {
|
|
return
|
|
}
|
|
const answer =
|
|
question.multiSelect && question.freeTextToken && selectedOptionIndexes.length > 0
|
|
? formatQuestionAnswerWithOtherByIndexes(question, selectedOptionIndexes, trimmedFreeText)
|
|
: formatQuestionFreeTextAnswer(question, trimmedFreeText)
|
|
if (await sendAnswer(answer)) {
|
|
setFreeText('')
|
|
}
|
|
}
|
|
|
|
const canSubmitMulti = selectedOptionIndexes.length > 0 && !sending
|
|
const canSendFreeText = allowOther && trimmedFreeText.length > 0 && !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>
|
|
{onCancel ? (
|
|
<Pressable
|
|
accessibilityLabel="Cancel"
|
|
hitSlop={8}
|
|
style={styles.cancel}
|
|
onPress={() => void onCancel(question.prompt)}
|
|
disabled={sending}
|
|
>
|
|
<X size={16} color={colors.textMuted} />
|
|
</Pressable>
|
|
) : null}
|
|
</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={styles.freeInput}
|
|
value={freeText}
|
|
onChangeText={setFreeText}
|
|
placeholder={hasOptions ? 'Or type a reply…' : 'Type your reply…'}
|
|
placeholderTextColor={colors.textMuted}
|
|
selectionColor={colors.accentBlue}
|
|
onSubmitEditing={submitFreeText}
|
|
returnKeyType="send"
|
|
multiline
|
|
/>
|
|
<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
|
|
},
|
|
cancel: {
|
|
width: 28,
|
|
height: 28,
|
|
alignItems: 'center',
|
|
justifyContent: 'center'
|
|
},
|
|
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
|
|
},
|
|
freeInput: {
|
|
flex: 1,
|
|
minHeight: 40,
|
|
maxHeight: 120,
|
|
color: colors.textPrimary,
|
|
fontSize: typography.bodySize + 1,
|
|
backgroundColor: colors.bgRaised,
|
|
borderRadius: radii.input,
|
|
paddingHorizontal: spacing.md,
|
|
paddingTop: spacing.sm,
|
|
paddingBottom: spacing.sm
|
|
},
|
|
freeSend: {
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 20,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: colors.textPrimary
|
|
},
|
|
freeSendDisabled: {
|
|
backgroundColor: colors.bgRaised
|
|
},
|
|
pressed: {
|
|
opacity: 0.7
|
|
}
|
|
})
|