fix(mobile): keep session option sends scoped

This commit is contained in:
Brennan Benson
2026-08-03 17:41:55 -07:00
parent c9b57c3e4b
commit 13f6d086c6
11 changed files with 368 additions and 132 deletions
+7 -65
View File
@@ -1951,56 +1951,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.52.0':
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.52.0':
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.52.0':
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.52.0':
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
@@ -2073,56 +2065,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.71.0':
resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.71.0':
resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.71.0':
resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.71.0':
resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.71.0':
resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.71.0':
resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.71.0':
resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.71.0':
resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==}
@@ -2584,42 +2568,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.1.3':
resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.1.3':
resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.1.3':
resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.1.3':
resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.1.3':
resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.1.3':
resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==}
@@ -4934,28 +4912,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -9717,7 +9691,7 @@ snapshots:
dependencies:
'@types/yargs-parser': 21.0.3
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)':
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
@@ -9737,7 +9711,7 @@ snapshots:
dependencies:
'@typescript-eslint/scope-manager': 8.59.2
'@typescript-eslint/types': 8.59.2
'@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3)
'@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.59.2
debug: 4.4.3
eslint: 9.39.4
@@ -9754,15 +9728,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.59.2(typescript@6.0.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
'@typescript-eslint/types': 8.59.2
debug: 4.4.3
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.59.2':
dependencies:
'@typescript-eslint/types': 8.59.2
@@ -9772,10 +9737,6 @@ snapshots:
dependencies:
typescript: 5.9.3
'@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)':
dependencies:
typescript: 6.0.3
'@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.59.2
@@ -9805,21 +9766,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)':
dependencies:
'@typescript-eslint/project-service': 8.59.2(typescript@6.0.3)
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
'@typescript-eslint/types': 8.59.2
'@typescript-eslint/visitor-keys': 8.59.2
debug: 4.4.3
minimatch: 10.2.5
semver: 7.7.4
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
@@ -10888,11 +10834,11 @@ snapshots:
eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
eslint: 9.39.4
eslint-config-prettier: 9.1.2(eslint@9.39.4)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)
eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3)
eslint-plugin-node: 11.1.0(eslint@9.39.4)
eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8)
@@ -10916,7 +10862,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4):
dependencies:
debug: 3.2.7
optionalDependencies:
@@ -10939,7 +10885,7 @@ snapshots:
eslint-utils: 2.1.0
regexpp: 3.2.0
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -10950,7 +10896,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.4
eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)
hasown: 2.0.3
is-core-module: 2.16.2
is-glob: 4.0.3
@@ -14133,10 +14079,6 @@ snapshots:
dependencies:
typescript: 5.9.3
ts-api-utils@2.5.0(typescript@6.0.3):
dependencies:
typescript: 6.0.3
ts-declaration-location@1.0.7(typescript@5.9.3):
dependencies:
picomatch: 4.0.4
@@ -92,10 +92,61 @@ describe('MobileNativeChatComposer', () => {
await act(async () => sendButton().props.onPress())
expect(onSend).toHaveBeenCalledWith('hello')
expect(onSend).toHaveBeenCalledWith(' hello')
expect(onChangeText).not.toHaveBeenCalled()
})
it('preserves leading whitespace so prose is not turned into a slash command', async () => {
const onSend = vi.fn().mockResolvedValue(true)
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: ' /clear is prose ',
onChangeText: vi.fn(),
onSend
})
)
})
} finally {
restore()
}
await act(async () => sendButton().props.onPress())
expect(onSend).toHaveBeenCalledWith(' /clear is prose')
})
it('blocks composer submission while a session-option command is pending', async () => {
const onSend = vi.fn().mockResolvedValue(true)
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: 'hello',
onChangeText: vi.fn(),
onSend,
sessionOptions: {
isWorking: false,
controller: {
snapshot: [],
pendingId: 'model',
setOption: vi.fn(),
invokeAction: vi.fn(),
recordCommand: vi.fn()
}
}
})
)
})
} finally {
restore()
}
expect(sendButton().props).toMatchObject({ disabled: true })
await act(async () => sendButton().props.onPress())
expect(onSend).not.toHaveBeenCalled()
})
it('keeps the draft when the send is rejected', async () => {
const onChangeText = vi.fn()
const onSend = vi.fn().mockResolvedValue(false)
@@ -103,7 +154,7 @@ describe('MobileNativeChatComposer', () => {
await act(async () => sendButton().props.onPress())
expect(onSend).toHaveBeenCalledWith('hello')
expect(onSend).toHaveBeenCalledWith(' hello')
expect(onChangeText).not.toHaveBeenCalled()
})
@@ -89,10 +89,15 @@ export function MobileNativeChatComposer({
const sendingRef = useRef(false)
const [sending, setSending] = useState(false)
const trimmed = value.trim()
const sessionOptionDispatching = sessionOptions?.controller.pendingId != null
// An attached image alone is a valid send (desktop parity), so the image rides
// along even when the user sends no accompanying text.
const canSend =
(trimmed.length > 0 || attachments.length > 0) && !disabled && !sending && !isAttaching
(trimmed.length > 0 || attachments.length > 0) &&
!disabled &&
!sending &&
!isAttaching &&
!sessionOptionDispatching
const trigger = useMemo(() => detectAutocompleteTrigger(value, cursor), [value, cursor])
const suggestions = useMemo<ComposerSuggestion[]>(() => {
@@ -143,7 +148,7 @@ export function MobileNativeChatComposer({
sendingRef.current = true
setSending(true)
try {
const accepted = await onSend(trimmed)
const accepted = await onSend(value.trimEnd())
if (accepted) {
setCursor(0)
}
@@ -32,6 +32,7 @@ export function MobileNativeChatComposerSuggestions({
{suggestions.map((suggestion) => (
<Pressable
key={composerSuggestionKey(suggestion)}
accessibilityRole="button"
style={({ pressed }) => [styles.suggestion, pressed && styles.suggestionPressed]}
onPress={() => onPick(suggestion)}
>
@@ -71,15 +71,39 @@ describe('MobileNativeChatSessionOptionPickers', () => {
})
}
const pill = (name: string): { props: { onPress: () => void; disabled?: boolean } } =>
const pill = (
name: string
): {
props: {
onPress: () => void
disabled?: boolean
accessibilityRole?: string
accessibilityState?: { disabled?: boolean }
}
} =>
renderer!.root.find(
(node) =>
node.type === 'Pressable' &&
typeof node.props.accessibilityLabel === 'string' &&
node.props.accessibilityLabel.startsWith(name)
) as { props: { onPress: () => void; disabled?: boolean } }
) as {
props: {
onPress: () => void
disabled?: boolean
accessibilityRole?: string
accessibilityState?: { disabled?: boolean }
}
}
const rowByText = (text: string): { props: { onPress: () => void } } => {
const rowByText = (
text: string
): {
props: {
onPress: () => void
accessibilityRole?: string
accessibilityState?: { checked?: boolean; disabled?: boolean }
}
} => {
const label = renderer!.root
.findAll((node) => node.type === 'Text')
.find((node) => (node.props as { children?: unknown }).children === text)
@@ -93,7 +117,13 @@ describe('MobileNativeChatSessionOptionPickers', () => {
if (!parent) {
throw new Error(`No pressable row for ${text}`)
}
return parent as unknown as { props: { onPress: () => void } }
return parent as unknown as {
props: {
onPress: () => void
accessibilityRole?: string
accessibilityState?: { checked?: boolean; disabled?: boolean }
}
}
}
beforeEach(() => {
@@ -132,6 +162,20 @@ describe('MobileNativeChatSessionOptionPickers', () => {
expect(setOption).toHaveBeenCalledWith('model', 'opus')
})
it('announces choice selection and disabled state', async () => {
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
expect(pill('Model').props).toMatchObject({
accessibilityRole: 'button',
accessibilityState: { disabled: false }
})
await act(async () => pill('Model').props.onPress())
expect(rowByText('Sonnet 5').props).toMatchObject({
accessibilityRole: 'radio',
accessibilityState: { checked: true, disabled: false }
})
expect(rowByText('Opus 4.8').props.accessibilityState?.checked).toBe(false)
})
it('closes without dispatch when re-picking the tracked value', async () => {
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
await act(async () => pill('Model').props.onPress())
@@ -149,6 +193,7 @@ describe('MobileNativeChatSessionOptionPickers', () => {
}
])
await act(async () => pill('Model').props.onPress())
expect(rowByText('Choose in agent picker…').props.accessibilityRole).toBe('button')
await act(async () => rowByText('Choose in agent picker…').props.onPress())
expect(invokeAction).toHaveBeenCalledWith('model')
})
@@ -36,6 +36,8 @@ function Pill({
return (
<Pressable
accessibilityLabel={accessibleName}
accessibilityRole="button"
accessibilityState={{ disabled }}
style={({ pressed }) => [styles.pill, pressed && !disabled && styles.pressed]}
onPress={onPress}
disabled={disabled}
@@ -68,6 +70,8 @@ function ChoiceRow({
}): React.JSX.Element {
return (
<Pressable
accessibilityRole="radio"
accessibilityState={{ checked: selected, disabled }}
style={[styles.row, selected && styles.rowSelected, disabled && styles.rowDisabled]}
onPress={onPress}
disabled={disabled}
@@ -98,6 +102,8 @@ function ActionRow({
}): React.JSX.Element {
return (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled }}
style={[styles.row, disabled && styles.rowDisabled]}
onPress={onPress}
disabled={disabled}
@@ -135,8 +141,7 @@ function DescriptorRows({
if (descriptor.action?.type === 'agent-picker') {
return <ActionRow label="Choose in agent picker…" disabled={locked} onPress={onInvokeAction} />
}
// Why: absolute On/Off only when we have tracked truth. Unknown composed
// booleans leave the group unselected so empty radios are not a selection.
// Unknown booleans leave both radios unselected instead of inventing truth.
if (descriptor.kind.type === 'boolean') {
const current = descriptor.kind.currentValue
return (
@@ -235,6 +240,7 @@ export function MobileNativeChatSessionOptionPickers({
) : null}
<Pressable
accessibilityLabel="Close picker"
accessibilityRole="button"
style={styles.sheetClose}
onPress={() => setOpen(null)}
hitSlop={8}
@@ -1,6 +1,5 @@
import {
useCallback,
useEffect,
useRef,
type Dispatch,
type MutableRefObject,
@@ -250,10 +249,6 @@ export function useMobileNativeChatController(args: {
// tracking, but the options hook needs the seam's dispatcher — a ref breaks
// the cycle without re-creating the send callbacks per snapshot.
const recordSessionOptionCommandRef = useRef<(command: string) => void>(() => {})
const handleCommandSend = useCallback(
(command: string) => recordSessionOptionCommandRef.current(command),
[]
)
const {
send: handleNativeChatSend,
@@ -266,7 +261,7 @@ export function useMobileNativeChatController(args: {
handleRef: activeHandleRef,
deviceTokenRef,
agentRef: activeChatAgentRef,
onCommandSend: handleCommandSend,
commandSendRef: recordSessionOptionCommandRef,
captureSendOrigin,
readSeededLaunchDraftSeed,
clearDraftForSend,
@@ -291,9 +286,7 @@ export function useMobileNativeChatController(args: {
dispatchCommand: handleNativeChatDispatchCommand,
onAgentPicker: handleAgentPicker
})
useEffect(() => {
recordSessionOptionCommandRef.current = sessionOptions.recordCommand
}, [sessionOptions.recordCommand])
recordSessionOptionCommandRef.current = sessionOptions.recordCommand
// Card actions retire the route's held failure banner too, not just sends.
const answerAsk = useNativeChatAcceptedAction(handleNativeChatAnswerAsk, onSendResolved)
const cancelAsk = useNativeChatAcceptedAction(handleNativeChatCancelAsk, onSendResolved)
@@ -31,19 +31,22 @@ describe('useMobileNativeChatMessageSend', () => {
const acceptSend = vi.fn()
const holdUnconfirmedSend = vi.fn()
const onCommandSend = vi.fn()
const commandSendRef = { current: onCommandSend }
const agentRef = { current: null as string | null }
const mount = (
readSeededLaunchDraftSeed: () => { text: string; createdAt: number | null } | null,
agent: string | null = 'claude'
): void => {
agentRef.current = agent
function Probe(): null {
api = useMobileNativeChatMessageSend({
client: { sendRequest: vi.fn() } as never,
enabled: true,
handleRef: { current: 'term' },
deviceTokenRef: { current: 'device' },
agentRef: { current: agent },
onCommandSend,
agentRef,
commandSendRef,
captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never,
readSeededLaunchDraftSeed,
clearDraftForSend: () => {},
@@ -79,6 +82,7 @@ describe('useMobileNativeChatMessageSend', () => {
acceptSend.mockReset()
holdUnconfirmedSend.mockReset()
onCommandSend.mockReset()
commandSendRef.current = onCommandSend
})
afterEach(() => {
act(() => {
@@ -205,14 +209,14 @@ describe('useMobileNativeChatMessageSend', () => {
})
it('never creates an optimistic echo for an unknown slash token', async () => {
// `/model` is not in Claude's verified catalog, but it still dispatches to
// the TUI — same no-echo rule, without claiming a verified command ran.
// `/model` is not in Claude's autocomplete catalog, but the session-option
// recorder still recognizes it without claiming a generic command ran.
mount(() => null)
await act(async () => {
await api!.send('/model sonnet')
})
expect(acceptSend).not.toHaveBeenCalled()
expect(onCommandSend).not.toHaveBeenCalled()
expect(onCommandSend).toHaveBeenCalledWith('/model sonnet')
})
it('classifies per agent: /model is a catalog command for Codex', async () => {
@@ -238,13 +242,59 @@ describe('useMobileNativeChatMessageSend', () => {
})
it('dispatchCommand surfaces the outcome without echo or composer sync', async () => {
mount(() => null)
mount(() => ({ text: DRAFT, createdAt: 1 }))
let outcome: string | undefined
await act(async () => {
outcome = await api!.dispatchCommand('/model sonnet')
})
expect(outcome).toBe('accepted')
expect(acceptSend).not.toHaveBeenCalled()
expect(onCommandSend).not.toHaveBeenCalled()
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
})
it('binds classification to the agent that started the send', async () => {
let resolveSend!: (outcome: MobileNativeChatSendOutcome) => void
sendWithOutcome.mockReturnValue(
new Promise<MobileNativeChatSendOutcome>((resolve) => {
resolveSend = resolve
})
)
mount(() => null, 'claude')
let sending!: Promise<boolean>
act(() => {
sending = api!.send('$skill')
})
agentRef.current = 'codex'
await act(async () => {
resolveSend('accepted')
await sending
})
expect(acceptSend).toHaveBeenCalledTimes(1)
})
it('records a command against the tab that started the send', async () => {
let resolveSend!: (outcome: MobileNativeChatSendOutcome) => void
sendWithOutcome.mockReturnValue(
new Promise<MobileNativeChatSendOutcome>((resolve) => {
resolveSend = resolve
})
)
const originalRecorder = vi.fn()
const nextRecorder = vi.fn()
commandSendRef.current = originalRecorder
mount(() => null)
commandSendRef.current = originalRecorder
let sending!: Promise<boolean>
act(() => {
sending = api!.send('/clear')
})
commandSendRef.current = nextRecorder
await act(async () => {
resolveSend('accepted')
await sending
})
expect(originalRecorder).toHaveBeenCalledWith('/clear')
expect(nextRecorder).not.toHaveBeenCalled()
})
})
@@ -40,9 +40,9 @@ export function useMobileNativeChatMessageSend(args: {
deviceTokenRef: MutableRefObject<string | null>
/** Active tab's agent — classification is per-agent (command catalogs differ). */
agentRef: MutableRefObject<string | null>
/** Fires after a catalog command send is accepted, so session-option state can
* track typed commands like `/model sonnet` (desktop recordOutgoingCommand parity). */
onCommandSend?: (command: string) => void
/** Captured when a control send starts so a later tab switch cannot record its
* session-option effects against the newly active tab. */
commandSendRef: MutableRefObject<(command: string) => void>
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
/** Launch-context text Orca parked on the agent's TUI input line, or null. Read
* at send time so the pre-clear can be sized to every line it occupies. */
@@ -63,7 +63,7 @@ export function useMobileNativeChatMessageSend(args: {
handleRef,
deviceTokenRef,
agentRef,
onCommandSend,
commandSendRef,
captureSendOrigin,
readSeededLaunchDraftSeed,
clearDraftForSend,
@@ -78,10 +78,13 @@ export function useMobileNativeChatMessageSend(args: {
text: string,
images: string[] | undefined,
syncComposer: boolean,
recordControlSend: boolean,
sharedDeadline?: number
): Promise<MobileNativeChatSendOutcome> => {
const handle = handleRef.current
const origin = captureSendOrigin(text)
const agent = agentRef.current
const recordCommand = commandSendRef.current
// Why: the lease collapses one render after `connState`, so a question-card
// answer (which reaches this send directly) would otherwise burn the whole
// 15s heal+send budget waiting on a socket that is already gone.
@@ -176,7 +179,7 @@ export function useMobileNativeChatMessageSend(args: {
// TUI, not the conversation — the transcript never echoes it as a user
// turn, so an optimistic bubble would sit at "Queued" forever and the
// unconfirmed hold could never observe a landing.
const classification = classifyMobileNativeChatSend(agentRef.current, text)
const classification = classifyMobileNativeChatSend(agent, text)
if (outcome === 'unknown') {
if (classification === 'chat') {
// Why: an ack-lost send usually WAS delivered (issue seen on cellular
@@ -198,8 +201,10 @@ export function useMobileNativeChatMessageSend(args: {
// `images` are local preview URIs for the optimistic echo only — the actual
// image bytes already rode along as a bracketed paste before this text send.
acceptSend(origin, text, images)
} else if (classification === 'command') {
onCommandSend?.(text.trim())
} else if (recordControlSend) {
// The session-option catalog can recognize controls omitted from the
// autocomplete catalog (for example Claude `/model` and `/fast`).
recordCommand(text.trim())
}
return 'accepted'
},
@@ -209,11 +214,11 @@ export function useMobileNativeChatMessageSend(args: {
captureSendOrigin,
clearDraftForSend,
client,
commandSendRef,
deviceTokenRef,
enabled,
handleRef,
holdUnconfirmedSend,
onCommandSend,
onSendError,
readSeededLaunchDraftSeed,
restoreRejectedDraft
@@ -222,7 +227,7 @@ export function useMobileNativeChatMessageSend(args: {
const sendWithOutcome = useCallback(
(text: string, images?: string[], deadline?: number) =>
sendMessage(text, images, true, deadline),
sendMessage(text, images, true, true, deadline),
[sendMessage]
)
@@ -237,12 +242,13 @@ export function useMobileNativeChatMessageSend(args: {
// A question answer is not composer text, so it never syncs the draft.
const answerQuestion = useCallback(
async (text: string): Promise<boolean> =>
(await sendMessage(text, undefined, false)) !== 'rejected',
(await sendMessage(text, undefined, false, true)) !== 'rejected',
[sendMessage]
)
const dispatchCommand = useCallback(
(text: string): Promise<MobileNativeChatSendOutcome> => sendMessage(text, undefined, false),
(text: string): Promise<MobileNativeChatSendOutcome> =>
sendMessage(text, undefined, false, false),
[sendMessage]
)
@@ -13,26 +13,36 @@ type HookArgs = Parameters<typeof useMobileNativeChatSessionOptions>[0]
describe('useMobileNativeChatSessionOptions', () => {
let renderer: ReactTestRenderer | null = null
let api: MobileNativeChatSessionOptionsController | null = null
let hookArgs: HookArgs
const dispatchCommand = vi.fn<(command: string) => Promise<MobileNativeChatSendOutcome>>()
const onAgentPicker = vi.fn()
function Probe(): null {
api = useMobileNativeChatSessionOptions(hookArgs)
return null
}
const mount = (overrides: Partial<HookArgs> = {}): void => {
function Probe(): null {
api = useMobileNativeChatSessionOptions({
agent: 'claude',
scopeKey: 'host\0worktree\0tab',
reportedModel: null,
dispatchCommand,
onAgentPicker,
...overrides
})
return null
hookArgs = {
agent: 'claude',
scopeKey: 'host\0worktree\0tab',
reportedModel: null,
dispatchCommand,
onAgentPicker,
...overrides
}
act(() => {
renderer = create(createElement(Probe))
})
}
const update = (overrides: Partial<HookArgs>): void => {
hookArgs = { ...hookArgs, ...overrides }
act(() => {
renderer!.update(createElement(Probe))
})
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
clearMobileSessionOptionRecordsForTests()
@@ -59,6 +69,11 @@ describe('useMobileNativeChatSessionOptions', () => {
expect(api!.snapshot).toEqual([])
})
it('does not expose catalog-backed agents outside the Claude and Codex scope', () => {
mount({ agent: 'gemini' })
expect(api!.snapshot).toEqual([])
})
it('applies a model pick through the catalog modelApply command', async () => {
mount()
let applied: boolean | undefined
@@ -119,4 +134,85 @@ describe('useMobileNativeChatSessionOptions', () => {
expect(effort).toMatchObject({ valueSource: 'dispatched' })
expect(effort!.kind).toMatchObject({ currentValue: 'low' })
})
it('keeps the latest queued operation pending until it settles', async () => {
const resolvers: Array<(outcome: MobileNativeChatSendOutcome) => void> = []
dispatchCommand.mockImplementation(
() =>
new Promise<MobileNativeChatSendOutcome>((resolve) => {
resolvers.push(resolve)
})
)
mount({ reportedModel: 'claude-sonnet-5' })
let first!: Promise<boolean>
let second!: Promise<boolean>
await act(async () => {
first = api!.setOption('effort', 'low')
second = api!.setOption('model', 'opus')
await Promise.resolve()
})
expect(api!.pendingId).toBe('model')
await act(async () => {
resolvers[0]!('accepted')
await Promise.resolve()
})
expect(dispatchCommand).toHaveBeenCalledTimes(2)
expect(api!.pendingId).toBe('model')
await act(async () => {
resolvers[1]!('accepted')
await Promise.all([first, second])
})
expect(api!.pendingId).toBeNull()
})
it('does not dispatch a queued option into a newly active tab', async () => {
let resolveFirst!: (outcome: MobileNativeChatSendOutcome) => void
dispatchCommand.mockImplementationOnce(
() =>
new Promise<MobileNativeChatSendOutcome>((resolve) => {
resolveFirst = resolve
})
)
mount({ reportedModel: 'claude-sonnet-5' })
let first!: Promise<boolean>
let queued!: Promise<boolean>
await act(async () => {
first = api!.setOption('effort', 'low')
queued = api!.setOption('model', 'opus')
await Promise.resolve()
})
update({ scopeKey: 'host\0worktree\0other-tab' })
await act(async () => {
resolveFirst('accepted')
await first
})
await expect(queued).resolves.toBe(false)
expect(dispatchCommand).toHaveBeenCalledTimes(1)
})
it('does not dispatch queued work after unmount', async () => {
let resolveFirst!: (outcome: MobileNativeChatSendOutcome) => void
dispatchCommand.mockImplementationOnce(
() =>
new Promise<MobileNativeChatSendOutcome>((resolve) => {
resolveFirst = resolve
})
)
mount({ reportedModel: 'claude-sonnet-5' })
let first!: Promise<boolean>
let queued!: Promise<boolean>
await act(async () => {
first = api!.setOption('effort', 'low')
queued = api!.setOption('model', 'opus')
await Promise.resolve()
})
act(() => {
renderer!.unmount()
})
renderer = null
resolveFirst('accepted')
await expect(first).resolves.toBe(true)
await expect(queued).resolves.toBe(false)
expect(dispatchCommand).toHaveBeenCalledTimes(1)
})
})
@@ -32,6 +32,8 @@ export type MobileNativeChatSessionOptionsController = {
recordCommand: (command: string) => void
}
type PendingOperation = { id: string; token: number }
// Why: per-tab records survive chat↔terminal flips and remounts, like desktop's
// scope cache. Bounded so long sessions across many tabs can't grow unbounded.
const MOBILE_SESSION_OPTION_RECORD_CAP = 32
@@ -71,13 +73,28 @@ export function useMobileNativeChatSessionOptions(args: {
onAgentPicker?: () => void
}): MobileNativeChatSessionOptionsController {
const { agent, scopeKey, reportedModel, dispatchCommand, onAgentPicker } = args
const catalog = useMemo(() => (agent ? getAgentSessionOptionCatalog(agent) : null), [agent])
const catalog = useMemo(
() => (agent === 'claude' || agent === 'codex' ? getAgentSessionOptionCatalog(agent) : null),
[agent]
)
const identity = agent && scopeKey ? `${scopeKey}\0${agent}` : null
const [version, setVersion] = useState(0)
const [pendingId, setPendingId] = useState<string | null>(null)
const [pendingByIdentity, setPendingByIdentity] = useState<
Record<string, PendingOperation | undefined>
>({})
const pendingId = identity ? (pendingByIdentity[identity]?.id ?? null) : null
const bump = useCallback(() => setVersion((current) => current + 1), [])
// Why: ordered applies — a later absolute target must observe the result of
// an earlier dispatch instead of computing against a stale baseline.
const applyQueueRef = useRef<Promise<unknown>>(Promise.resolve())
const activeIdentityRef = useRef(identity)
activeIdentityRef.current = identity
const applyQueuesRef = useRef(new Map<string, Promise<void>>())
const operationTokenRef = useRef(0)
useEffect(
() => () => {
activeIdentityRef.current = null
},
[]
)
// Seed the current model from live agent status; hook reports are authority
// over locally dispatched guesses (desktop 'reported' source parity).
@@ -104,23 +121,47 @@ export function useMobileNativeChatSessionOptions(args: {
return buildMobileSessionOptionSnapshot({ catalog, record: getScopedRecord(scopeKey, agent) })
}, [agent, catalog, scopeKey, version])
const runSerialized = useCallback(<T>(id: string, run: () => Promise<T>): Promise<T> => {
const chained = applyQueueRef.current.then(run, run)
applyQueueRef.current = chained.then(
() => undefined,
() => undefined
)
setPendingId(id)
void chained.finally(() => setPendingId(null))
return chained
}, [])
const runSerialized = useCallback(
(operationIdentity: string, id: string, run: () => Promise<boolean>): Promise<boolean> => {
const previous = applyQueuesRef.current.get(operationIdentity) ?? Promise.resolve()
const runIfCurrent = (): Promise<boolean> =>
activeIdentityRef.current === operationIdentity ? run() : Promise.resolve(false)
const chained = previous.then(runIfCurrent, runIfCurrent)
const tail = chained.then(
() => undefined,
() => undefined
)
applyQueuesRef.current.set(operationIdentity, tail)
operationTokenRef.current += 1
const token = operationTokenRef.current
setPendingByIdentity((current) => ({
...current,
[operationIdentity]: { id, token }
}))
void tail.then(() => {
if (applyQueuesRef.current.get(operationIdentity) === tail) {
applyQueuesRef.current.delete(operationIdentity)
}
setPendingByIdentity((current) => {
if (current[operationIdentity]?.token !== token) {
return current
}
const next = { ...current }
delete next[operationIdentity]
return next
})
})
return chained
},
[]
)
const setOption = useCallback(
(id: string, value: SessionOptionValue): Promise<boolean> => {
if (!catalog || !scopeKey || !agent) {
if (!catalog || !scopeKey || !agent || !identity) {
return Promise.resolve(false)
}
return runSerialized(id, async () => {
return runSerialized(identity, id, async () => {
const record = getScopedRecord(scopeKey, agent)
const previousModelId = typeof record.model?.value === 'string' ? record.model.value : null
const apply =
@@ -176,15 +217,15 @@ export function useMobileNativeChatSessionOptions(args: {
return true
})
},
[agent, bump, catalog, dispatchCommand, runSerialized, scopeKey]
[agent, bump, catalog, dispatchCommand, identity, runSerialized, scopeKey]
)
const invokeAction = useCallback(
(id: string): Promise<boolean> => {
if (!catalog || !scopeKey || !agent) {
if (!catalog || !scopeKey || !agent || !identity) {
return Promise.resolve(false)
}
return runSerialized(id, async () => {
return runSerialized(identity, id, async () => {
const record = getScopedRecord(scopeKey, agent)
const modelId = typeof record.model?.value === 'string' ? record.model.value : null
const apply =
@@ -211,7 +252,7 @@ export function useMobileNativeChatSessionOptions(args: {
return false
})
},
[agent, bump, catalog, dispatchCommand, onAgentPicker, runSerialized, scopeKey]
[agent, bump, catalog, dispatchCommand, identity, onAgentPicker, runSerialized, scopeKey]
)
const recordCommand = useCallback(