diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts b/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts index 443203a3fbf..1a34b788384 100644 --- a/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts +++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts @@ -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 = { diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx index fbd7e6e88f4..834a0a5ed15 100644 --- a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx +++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx @@ -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' : ''}` diff --git a/mobile/src/session/MobileNativeChatSessionOptionRows.tsx b/mobile/src/session/MobileNativeChatSessionOptionRows.tsx index 7f636163ee1..9135d192dfc 100644 --- a/mobile/src/session/MobileNativeChatSessionOptionRows.tsx +++ b/mobile/src/session/MobileNativeChatSessionOptionRows.tsx @@ -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 ( - {warning ? : null} - - {primary} - {secondary ? {` ${secondary}`} : null} - + {PillIcon ? ( + + ) : null} + {!Icon ? ( + + {primary} + {secondary ? {` ${secondary}`} : null} + + ) : null} ) } diff --git a/src/renderer/src/components/native-chat/native-chat-permission-mode-labels.ts b/src/renderer/src/components/native-chat/native-chat-permission-mode-labels.ts index bf1d033eed8..630029a61cd 100644 --- a/src/renderer/src/components/native-chat/native-chat-permission-mode-labels.ts +++ b/src/renderer/src/components/native-chat/native-chat-permission-mode-labels.ts @@ -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 = { + 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 export function nativeChatPermissionModeLabel(mode: AgentChatPermissionMode): string { diff --git a/src/shared/agent-chat-permission-mode.ts b/src/shared/agent-chat-permission-mode.ts index b15dfde747f..e6447636683 100644 --- a/src/shared/agent-chat-permission-mode.ts +++ b/src/shared/agent-chat-permission-mode.ts @@ -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 + +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'