mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user