fix(mobile): keep the chat composer's controls inside its row (#28983)

* fix(mobile): keep the chat composer's controls inside its row

The model pill never shrank (React Native items default to flexShrink 0), so a
long model name slid under Mic and Send. The permission pill now shows only its
mode icon, resolved from one shared mode->icon-name table that desktop uses too.

* fix(mobile): resolve the permission icon only when a pill renders

* style: format the permission pill test

* fix(desktop): keep the permission icons a static record
This commit is contained in:
Brennan Benson
2026-10-20 11:44:35 -07:00
committed by GitHub
parent 2bc32b7dbe
commit 38034167e7
5 changed files with 118 additions and 25 deletions
@@ -1,6 +1,7 @@
import { createElement, useState } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { colors } from '../theme/mobile-theme'
import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options'
import {
MobileNativeChatSessionOptionPickers,
@@ -22,7 +23,10 @@ vi.mock('lucide-react-native', () => ({
ChevronDown: 'ChevronDown',
ChevronLeft: 'ChevronLeft',
ChevronRight: 'ChevronRight',
FilePenLine: 'FilePenLine',
Hand: 'Hand',
ShieldAlert: 'ShieldAlert',
ShieldCheck: 'ShieldCheck',
X: 'X'
}))
vi.mock('../components/BottomDrawer', async () => {
@@ -333,18 +337,41 @@ describe('MobileNativeChatSessionOptionPickers', () => {
expect(rowByText('Sonnet 5').props.accessibilityRole).toBe('radio')
})
it('names the pill for the permission mode where the agent has no model, and closes on a pick', async () => {
mount([PERMISSION_DESCRIPTOR], true)
expect(pill('Permissions').props).toMatchObject({
accessibilityLabel: 'Permissions, Ask for approval',
disabled: false
})
setOption.mockImplementation(() => new Promise(() => {}))
await act(async () => pill('Permissions').props.onPress())
await act(async () => rowByText('Full access').props.onPress())
expect(setOption).toHaveBeenCalledWith('permissionMode', 'full-access')
expect(renderer!.root.findAllByType('BottomDrawer')).toEqual([])
})
it.each([
['ask', 'Ask for approval', 'Hand', 'full-access', 'Full access'],
['full-access', 'Full access', 'ShieldAlert', 'ask', 'Ask for approval'],
['host', "Host's Agent Permissions", null, 'ask', 'Ask for approval']
] as const)(
'names the permission-only %s pill and closes its labeled sheet on a pick',
async (value, label, icon, nextValue, nextLabel) => {
const choices = [
{ value, label },
{ value: nextValue, label: nextLabel }
]
const kind = { type: 'select' as const, currentValue: value, choices }
mount([{ ...PERMISSION_DESCRIPTOR, kind }], true)
expect(pill('Permissions').props).toMatchObject({
accessibilityLabel: `Permissions, ${label}`,
disabled: false
})
expect(renderer!.root.findAllByType('Text')).toHaveLength(icon ? 0 : 2)
expect(renderer!.root.findAllByType('ShieldAlert')).toHaveLength(
value === 'full-access' ? 1 : 0
)
if (icon) {
expect(renderer!.root.findAllByType(icon)).toHaveLength(1)
expect(renderer!.root.findByType(icon).props.color).toBe(
value === 'full-access' ? colors.statusAmber : colors.textSecondary
)
}
setOption.mockImplementation(() => new Promise(() => {}))
await act(async () => pill('Permissions').props.onPress())
expect(rowByText(nextLabel).props.accessibilityRole).toBe('radio')
await act(async () => rowByText(nextLabel).props.onPress())
expect(setOption).toHaveBeenCalledWith('permissionMode', nextValue)
expect(renderer!.root.findAllByType('BottomDrawer')).toEqual([])
}
)
})
const PERMISSION_DESCRIPTOR: SessionOptionDescriptor = {
@@ -1,5 +1,13 @@
import { ActivityIndicator, Keyboard, Pressable, StyleSheet, Text, View } from 'react-native'
import { ChevronLeft, X } from 'lucide-react-native'
import {
ChevronLeft,
FilePenLine,
Hand,
ShieldAlert,
ShieldCheck,
X,
type LucideIcon
} from 'lucide-react-native'
import { BottomDrawer } from '../components/BottomDrawer'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import {
@@ -25,7 +33,11 @@ import {
showsSessionOptionAsSegments
} from './MobileNativeChatSessionOptionSegments'
import { sortNativeChatSessionOptions } from '../../../src/shared/native-chat-session-option-snapshot'
import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../src/shared/agent-chat-permission-mode'
import {
AGENT_CHAT_PERMISSION_MODE_ICON_NAMES,
AGENT_CHAT_PERMISSION_MODE_OPTION_ID,
isAgentChatPermissionMode
} from '../../../src/shared/agent-chat-permission-mode'
import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options'
export type MobileNativeChatPickerPresentation = { page: string | null; refocus: boolean }
@@ -56,6 +68,23 @@ function permissionChoiceColor(value: string | undefined): string | undefined {
return value === 'full-access' ? colors.statusAmber : undefined
}
/** Undefined for a mode a newer host states that this build has no icon for. */
function permissionModeIcon(value: string): LucideIcon | undefined {
if (!isAgentChatPermissionMode(value)) {
return undefined
}
switch (AGENT_CHAT_PERMISSION_MODE_ICON_NAMES[value]) {
case 'Hand':
return Hand
case 'FilePenLine':
return FilePenLine
case 'ShieldCheck':
return ShieldCheck
case 'ShieldAlert':
return ShieldAlert
}
}
function selectedChoice(descriptor: SessionOptionDescriptor | undefined) {
return descriptor?.kind.type === 'select'
? descriptor.kind.choices.find((choice) => choice.value === descriptor.kind.currentValue)
@@ -92,6 +121,8 @@ export function MobileNativeChatSessionOptionPickers({
const permissionChoice = selectedChoice(permission)
const permissionColor = permissionChoiceColor(permissionChoice?.value)
const fullAccess = permissionColor !== undefined
const permissionIcon =
!model && permissionChoice ? permissionModeIcon(permissionChoice.value) : undefined
const segmentOptions = options.filter(showsSessionOptionAsSegments)
const rowOptions = options.filter((descriptor) => !segmentOptions.includes(descriptor))
const primary = model ? mobileModelPillLabel(model) : (permissionChoice?.label ?? 'Permissions')
@@ -132,6 +163,7 @@ export function MobileNativeChatSessionOptionPickers({
primary={primary}
secondary={secondary}
warning={fullAccess}
icon={permissionIcon}
accessibleName={
model
? `Model, ${secondary ? `${primary} ${secondary}` : primary}${fullAccess ? ', full access' : ''}`
@@ -2,7 +2,7 @@
// beside it so the card file stays about layout and apply wiring.
import { Pressable, StyleSheet, Switch, Text, View } from 'react-native'
import { Check, ChevronRight, ShieldAlert } from 'lucide-react-native'
import { Check, ChevronRight, ShieldAlert, type LucideIcon } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import {
sessionOptionValueMarker,
@@ -21,6 +21,7 @@ export function ModelPill({
primary,
secondary,
warning,
icon: Icon,
accessibleName,
disabled,
onPress
@@ -28,10 +29,12 @@ export function ModelPill({
primary: string
secondary: string | null
warning: boolean
icon?: LucideIcon
accessibleName: string
disabled: boolean
onPress: () => void
}): React.JSX.Element {
const PillIcon = Icon ?? (warning ? ShieldAlert : undefined)
return (
<Pressable
accessibilityLabel={accessibleName}
@@ -42,11 +45,19 @@ export function ModelPill({
disabled={disabled}
hitSlop={6}
>
{warning ? <ShieldAlert size={15} color={colors.statusAmber} strokeWidth={2.2} /> : null}
<Text style={styles.pillText} numberOfLines={1} ellipsizeMode="tail">
<Text style={[styles.pillPrimary, disabled && styles.pillTextDisabled]}>{primary}</Text>
{secondary ? <Text style={styles.pillSecondary}>{` ${secondary}`}</Text> : null}
</Text>
{PillIcon ? (
<PillIcon
size={15}
color={warning ? colors.statusAmber : disabled ? colors.textMuted : colors.textSecondary}
strokeWidth={2.2}
/>
) : null}
{!Icon ? (
<Text style={styles.pillText} numberOfLines={1} ellipsizeMode="tail">
<Text style={[styles.pillPrimary, disabled && styles.pillTextDisabled]}>{primary}</Text>
{secondary ? <Text style={styles.pillSecondary}>{` ${secondary}`}</Text> : null}
</Text>
) : null}
</Pressable>
)
}
@@ -1,13 +1,25 @@
import type { LucideIcon } from 'lucide-react'
import { FilePenLine, Hand, ShieldAlert, ShieldCheck } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import type { AgentChatPermissionMode } from '../../../../shared/agent-chat-permission-mode'
import {
AGENT_CHAT_PERMISSION_MODE_ICON_NAMES,
type AgentChatPermissionMode,
type AgentChatPermissionModeIconName
} from '../../../../shared/agent-chat-permission-mode'
const ICONS_BY_NAME: Record<AgentChatPermissionModeIconName, LucideIcon> = {
Hand,
FilePenLine,
ShieldCheck,
ShieldAlert
}
// A static record, not a lookup function: render must index it, not call it.
export const NATIVE_CHAT_PERMISSION_MODE_ICONS = {
ask: Hand,
'accept-edits': FilePenLine,
auto: ShieldCheck,
'full-access': ShieldAlert
ask: ICONS_BY_NAME[AGENT_CHAT_PERMISSION_MODE_ICON_NAMES.ask],
'accept-edits': ICONS_BY_NAME[AGENT_CHAT_PERMISSION_MODE_ICON_NAMES['accept-edits']],
auto: ICONS_BY_NAME[AGENT_CHAT_PERMISSION_MODE_ICON_NAMES.auto],
'full-access': ICONS_BY_NAME[AGENT_CHAT_PERMISSION_MODE_ICON_NAMES['full-access']]
} as const satisfies Record<AgentChatPermissionMode, LucideIcon>
export function nativeChatPermissionModeLabel(mode: AgentChatPermissionMode): string {
+11
View File
@@ -17,6 +17,17 @@ export const AGENT_CHAT_PERMISSION_MODES = [
'full-access'
] as const satisfies readonly AgentChatPermissionMode[]
/** The lucide glyph each mode shows; desktop and phone resolve the name in their own icon package. */
export const AGENT_CHAT_PERMISSION_MODE_ICON_NAMES = {
ask: 'Hand',
'accept-edits': 'FilePenLine',
auto: 'ShieldCheck',
'full-access': 'ShieldAlert'
} as const satisfies Record<AgentChatPermissionMode, string>
export type AgentChatPermissionModeIconName =
(typeof AGENT_CHAT_PERMISSION_MODE_ICON_NAMES)[AgentChatPermissionMode]
/** The chat option a mode is saved and written under. */
export const AGENT_CHAT_PERMISSION_MODE_OPTION_ID = 'permissionMode'