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'