Show a Fast-only speed as the Fast mode switch on mobile (#40523)

On the phone, Codex's Standard/Fast speed was a Speed row opening a
list while Claude's Fast mode was a switch. A Fast-only speed now uses
the same switch row; more speeds, or a lone Flex, keep the list. The
lone-Fast rule moves to shared code so desktop and mobile follow one
rule.
This commit is contained in:
Brennan Benson
2026-11-09 18:11:52 -08:00
committed by GitHub
parent 0d5d870800
commit 80937f7eed
6 changed files with 94 additions and 24 deletions
@@ -160,6 +160,31 @@ describe('MobileNativeChatSessionOptionPickers', () => {
renderer = null
})
it('toggles a Fast-only Codex speed on the same Fast mode switch as Claude', async () => {
const speed: SessionOptionDescriptor = {
id: 'serviceTier',
label: 'Speed',
kind: {
type: 'select',
currentValue: 'default',
choices: [
{ value: 'default', label: 'Standard' },
{ value: 'priority', label: 'Fast' }
]
},
valueSource: 'reported',
transport: 'catalog',
settable: true
}
mount([MODEL_DESCRIPTOR, speed], false, 'model')
const toggle = findAllByHostType(renderer!.root, 'Switch').find(
(node) => node.props.accessibilityLabel === 'Fast mode'
)
expect(toggle?.props.value).toBe(false)
await act(async () => toggle!.props.onValueChange(true))
expect(setOption).toHaveBeenCalledWith('serviceTier', 'priority')
})
it('consumes a refused page without replay, then permits a fresh open and permission pick', async () => {
const placeholder: SessionOptionDescriptor = {
...MODEL_DESCRIPTOR,
@@ -36,7 +36,8 @@ import {
descriptorRowElements,
ModelPill,
SessionOptionCaption,
SessionOptionSummaryRow
SessionOptionSummaryRow,
showsSessionOptionAsSwitch
} from './MobileNativeChatSessionOptionRows'
import {
SessionOptionSegments,
@@ -383,7 +384,7 @@ function OptionRows({
{descriptors.flatMap((descriptor) => {
const isPermission = descriptor === permission
const disabled = locked(descriptor) || !descriptor.settable
if (descriptor.kind.type === 'boolean' && !descriptor.action) {
if (showsSessionOptionAsSwitch(descriptor)) {
return descriptorRowElements(
{
descriptor,
@@ -17,6 +17,10 @@ import {
nativeChatSessionOptionActionLabel,
nativeChatSessionOptionLabel
} from '../../../../../src/shared/native-chat/input/native-chat-session-option-labels'
import {
loneFastTier,
speedButtonTiers
} from '../../../../../src/shared/native-chat/input/native-chat-session-speed-tiers'
import { sayMobileNativeChatSessionOption as say } from './native-chat-session-option-copy'
import { MobileSwitch } from '../../../components/MobileSwitch'
@@ -131,6 +135,20 @@ type DescriptorRowsProps = {
moreChoices?: { shown: boolean; onToggle: () => void }
}
/** A Fast-only speed, which toggles on one row like Claude's Fast mode. */
function fastSpeedSwitch(descriptor: SessionOptionDescriptor) {
const speed = speedButtonTiers(descriptor)
const fast = speed ? loneFastTier(speed.tiers) : undefined
return speed && fast ? { standard: speed.standard, fast } : null
}
export function showsSessionOptionAsSwitch(descriptor: SessionOptionDescriptor): boolean {
return (
!descriptor.action &&
(descriptor.kind.type === 'boolean' || fastSpeedSwitch(descriptor) !== null)
)
}
/** One descriptor's rows as an array, so a ListGroup can draw the separators between them. */
export function descriptorRowElements(
{
@@ -156,8 +174,10 @@ export function descriptorRowElements(
]
}
// An unknown baseline still renders a switch; the source marker prevents implying the value was confirmed.
if (descriptor.kind.type === 'boolean') {
const label = nativeChatSessionOptionLabel(descriptor, say)
const fastSpeed = fastSpeedSwitch(descriptor)
if (descriptor.kind.type === 'boolean' || fastSpeed) {
const { kind } = descriptor
const label = fastSpeed ? say('fastMode') : nativeChatSessionOptionLabel(descriptor, say)
const marker = sessionOptionValueMarker(descriptor)
return [
<ListRow
@@ -173,8 +193,16 @@ export function descriptorRowElements(
accessory={
<MobileSwitch
accessibilityLabel={label}
value={descriptor.kind.currentValue}
onValueChange={(next) => onSetOption(next)}
value={
kind.type === 'boolean'
? kind.currentValue
: kind.currentValue === fastSpeed?.fast.value
}
onValueChange={(next) =>
onSetOption(
fastSpeed ? (next ? fastSpeed.fast.value : fastSpeed.standard.value) : next
)
}
disabled={locked}
/>
}
@@ -22,7 +22,8 @@ import {
} from '../../../../../../../shared/native-chat/input/native-chat-session-option-labels'
import { sayNativeChatSessionOptionTranslated as say } from './native-chat-session-option-labels-text'
import { NativeChatSessionOptionWriteGroup } from './NativeChatSessionOptionWriteGroup'
import { NativeChatSessionSpeedButton, speedButtonTiers } from './NativeChatSessionSpeedButton'
import { NativeChatSessionSpeedButton } from './NativeChatSessionSpeedButton'
import { speedButtonTiers } from '../../../../../../../shared/native-chat/input/native-chat-session-speed-tiers'
type NativeChatSessionOptionRowsProps = {
/** The current model's options, in display order. */
@@ -8,28 +8,14 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import type {
SessionOptionDescriptor,
SessionOptionSelectChoice,
SessionOptionValue
} from '../../../../../../../shared/native-chat/input/native-chat-session-options'
import { nativeChatSessionChoiceLabel } from '../../../../../../../shared/native-chat/input/native-chat-session-option-labels'
import { isFastServiceTier } from '../../../../../../../shared/agent-session/options/structured-agent-session-discovered-model'
import { loneFastTier } from '../../../../../../../shared/native-chat/input/native-chat-session-speed-tiers'
import { sayNativeChatSessionOptionTranslated as say } from './native-chat-session-option-labels-text'
const STANDARD_TIER = 'default'
/** Standard plus the other tiers, or null when the select is not a speed choice with any. */
export function speedButtonTiers(
descriptor: SessionOptionDescriptor
): { standard: SessionOptionSelectChoice; tiers: SessionOptionSelectChoice[] } | null {
if (descriptor.id !== 'serviceTier' || descriptor.kind.type !== 'select') {
return null
}
const standard = descriptor.kind.choices.find((choice) => choice.value === STANDARD_TIER)
const tiers = descriptor.kind.choices.filter((choice) => choice.value !== STANDARD_TIER)
return standard && tiers.length > 0 ? { standard, tiers } : null
}
/** A lone Fast tier toggles like Claude's Fast; anything else opens a list instead of a segmented switcher. */
export function NativeChatSessionSpeedButton(props: {
label: string
@@ -46,8 +32,8 @@ export function NativeChatSessionSpeedButton(props: {
const nameOf = (choice: SessionOptionSelectChoice) =>
nativeChatSessionChoiceLabel(choice, say, 'serviceTier')
// Only Fast takes the toggle and bolt; Flex and other tiers are picked from the list.
if (tiers.length === 1 && isFastServiceTier(tiers[0])) {
const only = tiers[0]
const only = loneFastTier(tiers)
if (only) {
return (
<Toggle
variant="chip"
@@ -0,0 +1,29 @@
import type {
SessionOptionDescriptor,
SessionOptionSelectChoice
} from './native-chat-session-options'
import { isFastServiceTier } from '../../agent-session/options/structured-agent-session-discovered-model'
const STANDARD_TIER = 'default'
export type SessionSpeedTiers = {
standard: SessionOptionSelectChoice
tiers: SessionOptionSelectChoice[]
}
/** Standard plus the other tiers, or null when the select is not a speed choice with any. */
export function speedButtonTiers(descriptor: SessionOptionDescriptor): SessionSpeedTiers | null {
if (descriptor.id !== 'serviceTier' || descriptor.kind.type !== 'select') {
return null
}
const standard = descriptor.kind.choices.find((choice) => choice.value === STANDARD_TIER)
const tiers = descriptor.kind.choices.filter((choice) => choice.value !== STANDARD_TIER)
return standard && tiers.length > 0 ? { standard, tiers } : null
}
/** A lone Fast tier toggles like Claude's Fast; Flex and several tiers are picked from a list. */
export function loneFastTier(
tiers: readonly SessionOptionSelectChoice[]
): SessionOptionSelectChoice | undefined {
return tiers.length === 1 && isFastServiceTier(tiers[0]) ? tiers[0] : undefined
}