Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config/scripts/vitest-node-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export const NODE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts',
'src/main/native-chat/structured-chat-naming-command-first.test.ts',
'src/main/native-chat/structured-chat-naming-status-hook.test.ts',
'src/main/runtime/agent-session-conversation-name-store.test.ts',
Expand Down
31 changes: 31 additions & 0 deletions mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,4 +290,35 @@ describe('MobileNativeChatSessionOptionPickers', () => {
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR], true)
expect(pill('Model').props).toMatchObject({ disabled: true })
})

it('shows the permission pill before any model pill, and closes at once on a pick', async () => {
mount([PERMISSION_DESCRIPTOR], true)
expect(pill('Permissions').props).toMatchObject({
accessibilityLabel: 'Permissions, Ask for approval',
disabled: false
})
// A write that waits behind a starting agent never holds the sheet open.
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([])
})
})

const PERMISSION_DESCRIPTOR: SessionOptionDescriptor = {
id: 'permissionMode',
label: 'Permissions',
category: 'mode',
kind: {
type: 'select',
currentValue: 'ask',
choices: [
{ value: 'ask', label: 'Ask for approval' },
{ value: 'full-access', label: 'Full access' }
]
},
valueSource: 'applied',
transport: 'agent-session',
settable: true
}
75 changes: 57 additions & 18 deletions mobile/src/session/MobileNativeChatSessionOptionPickers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
SessionOptionSummaryRow
} from './MobileNativeChatSessionOptionRows'
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 type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options'

export type MobileNativeChatSessionOptionPickersProps = {
Expand All @@ -35,7 +36,12 @@ export type MobileNativeChatSessionOptionPickersProps = {
sendInFlight?: boolean
}

/** Combined model/session-option trigger and its mobile bottom drawer. */
/** Full access reads in the warning colour wherever it is shown. */
function permissionChoiceColor(value: string | undefined): string | undefined {
return value === 'full-access' ? colors.statusAmber : undefined
}

/** The permission pill, then the combined model/session-option pill, sharing one bottom drawer. */
export function MobileNativeChatSessionOptionPickers({
controller,
isWorking,
Expand All @@ -49,22 +55,27 @@ export function MobileNativeChatSessionOptionPickers({
}
const { snapshot, pendingId } = controller
const model = snapshot.find((descriptor) => descriptor.category === 'model')
const permission = snapshot.find(
(descriptor) => descriptor.id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID
)
const options = sortNativeChatSessionOptions(snapshot)
if (!model) {
if (!model && !permission) {
return null
}
const disabled = isWorking || pendingId !== null || sendInFlight
const activeDescriptor = snapshot.find((descriptor) => descriptor.id === openDescriptorId)
const modelView = activeDescriptor?.id === model.id
const modelLabel = mobileModelPillLabel(model)
const permissionView = activeDescriptor !== undefined && activeDescriptor === permission
// The permission mode applies from the next send, so a running turn never locks it.
const disabled = (!permissionView && isWorking) || pendingId !== null || sendInFlight
const modelView = model !== undefined && activeDescriptor?.id === model.id
const modelLabel = model ? mobileModelPillLabel(model) : ''
const optionsLabel = options.length > 0 ? mobileOptionsPillLabel(options) : null
const pillLabel = optionsLabel ? `${modelLabel} ${optionsLabel}` : modelLabel
const reason = mobileSessionOptionDisabledReason(activeDescriptor?.disabledReason)

const closePicker = (): void => setOpenDescriptorId(null)
const openPicker = (): void => {
Keyboard.dismiss()
setOpenDescriptorId(model.id)
setOpenDescriptorId(model?.id ?? null)
}

const applyOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => {
Expand All @@ -77,6 +88,10 @@ export function MobileNativeChatSessionOptionPickers({
closePicker()
return
}
// A permission pick shows at once; its write may wait behind a starting agent.
if (descriptor === permission) {
closePicker()
}
void controller.setOption(descriptor.id, value).then((applied) => {
if (applied) {
closePicker()
Expand All @@ -91,26 +106,46 @@ export function MobileNativeChatSessionOptionPickers({
})
}

const permissionChoice =
permission?.kind.type === 'select'
? permission.kind.choices.find((choice) => choice.value === permission.kind.currentValue)
: undefined
const pillsBusy = pendingId !== null || sendInFlight

return (
<View style={styles.pillSlot}>
<Pill
label={pillLabel}
accessibleName={`Model, ${pillLabel}`}
disabled={disabled}
onPress={openPicker}
/>
<View style={styles.pills}>
{permission && permissionChoice ? (
<Pill
label={permissionChoice.label}
accessibleName={`Permissions, ${permissionChoice.label}`}
disabled={pillsBusy}
labelColor={permissionChoiceColor(permissionChoice.value)}
onPress={() => {
Keyboard.dismiss()
setOpenDescriptorId(permission.id)
}}
/>
) : null}
{model ? (
<Pill
label={pillLabel}
accessibleName={`Model, ${pillLabel}`}
disabled={isWorking || pillsBusy}
onPress={openPicker}
/>
) : null}
<BottomDrawer visible={activeDescriptor !== undefined} onClose={closePicker}>
{activeDescriptor ? (
<View style={styles.sheet}>
<View style={styles.sheetHeader}>
<Pressable
accessibilityLabel={modelView ? 'Close picker' : 'Back to models'}
accessibilityLabel={modelView || permissionView ? 'Close picker' : 'Back to models'}
accessibilityRole="button"
style={({ pressed }) => [styles.sheetNav, pressed && styles.pressed]}
onPress={modelView ? closePicker : () => setOpenDescriptorId(model.id)}
onPress={modelView || permissionView ? closePicker : openPicker}
hitSlop={8}
>
{modelView ? (
{modelView || permissionView ? (
<X size={18} color={colors.textSecondary} strokeWidth={2.2} />
) : (
<ChevronLeft size={18} color={colors.textSecondary} strokeWidth={2.2} />
Expand All @@ -136,6 +171,7 @@ export function MobileNativeChatSessionOptionPickers({
grouped
onSetOption={(value) => applyOption(activeDescriptor, value)}
onInvokeAction={() => invokeAction(activeDescriptor)}
{...(permissionView ? { choiceLabelColor: permissionChoiceColor } : {})}
/>
</View>
{modelView && options.length > 0 ? (
Expand All @@ -161,9 +197,12 @@ export function MobileNativeChatSessionOptionPickers({

const styles = StyleSheet.create({
// The composer row's only shrinkable item: a long label truncates before it pushes Send off.
pillSlot: {
pills: {
flexDirection: 'row',
alignItems: 'center',
flexShrink: 1,
minWidth: 0
minWidth: 0,
gap: spacing.xs
},
sheet: {
paddingBottom: spacing.xs
Expand Down
22 changes: 17 additions & 5 deletions mobile/src/session/MobileNativeChatSessionOptionRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ export function Pill({
label,
accessibleName,
disabled,
onPress
onPress,
labelColor
}: {
label: string
accessibleName: string
disabled: boolean
onPress: () => void
labelColor?: string
}): React.JSX.Element {
return (
<Pressable
Expand All @@ -38,7 +40,11 @@ export function Pill({
hitSlop={6}
>
<Text
style={[styles.pillText, disabled && styles.pillTextDisabled]}
style={[
styles.pillText,
labelColor ? { color: labelColor } : null,
disabled && styles.pillTextDisabled
]}
numberOfLines={1}
ellipsizeMode="tail"
>
Expand All @@ -56,7 +62,8 @@ function ChoiceRow({
disabled,
grouped,
divided,
onPress
onPress,
labelColor
}: {
label: string
description?: string
Expand All @@ -65,6 +72,7 @@ function ChoiceRow({
grouped: boolean
divided: boolean
onPress: () => void
labelColor?: string
}): React.JSX.Element {
return (
<Pressable
Expand All @@ -84,7 +92,7 @@ function ChoiceRow({
{selected ? <Check size={12} color={colors.bgBase} strokeWidth={3} /> : null}
</View>
<View style={styles.rowBody}>
<Text style={styles.rowLabel}>{label}</Text>
<Text style={[styles.rowLabel, labelColor ? { color: labelColor } : null]}>{label}</Text>
{description ? (
<Text style={styles.rowDescription} numberOfLines={2}>
{description}
Expand Down Expand Up @@ -198,13 +206,16 @@ export function DescriptorRows({
disabled,
grouped = false,
onSetOption,
onInvokeAction
onInvokeAction,
choiceLabelColor
}: {
descriptor: SessionOptionDescriptor
disabled: boolean
grouped?: boolean
onSetOption: (value: SessionOptionValue) => void
onInvokeAction: () => void
/** A choice whose name reads in a colour of its own, as Full access does. */
choiceLabelColor?: (value: string) => string | undefined
}): React.JSX.Element {
const locked = disabled || !descriptor.settable
// Why: flip-only without a baseline is an action — never claim On/Off.
Expand Down Expand Up @@ -257,6 +268,7 @@ export function DescriptorRows({
grouped={grouped}
divided={grouped && index < choices.length - 1}
onPress={() => onSetOption(choice.value)}
labelColor={choiceLabelColor?.(choice.value)}
/>
))}
</>
Expand Down
11 changes: 10 additions & 1 deletion mobile/src/session/use-mobile-host-model-catalog-upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export function useMobileHostModelCatalogUpgrade(args: {
fence: number | null
optionCatalog: AgentSessionOptionCatalog | null
activeOptionRecordRef: MutableRefObject<NativeChatSessionOptionRecord>
/** A listing landed after the chat's options were read, which answered without it. */
onListingLanded?: () => void
updateOptionState: (
update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState
) => void
Expand All @@ -43,6 +45,7 @@ export function useMobileHostModelCatalogUpgrade(args: {
enabled,
fence,
newLaunch,
onListingLanded,
optionCatalog,
sessionId,
updateOptionState,
Expand Down Expand Up @@ -89,7 +92,12 @@ export function useMobileHostModelCatalogUpgrade(args: {
if (catalog.origin === 'unknown' && catalog.listingInProgress === true && !stale) {
// Usable on the built-in list while the listing runs; it lands in place.
apply(catalog)
return read(true).then(apply)
return read(true).then((listed) => {
apply(listed)
if (!stale && listed.origin !== 'unknown') {
onListingLanded?.()
}
})
}
apply(catalog)
return undefined
Expand All @@ -105,6 +113,7 @@ export function useMobileHostModelCatalogUpgrade(args: {
enabled,
fence,
newLaunch,
onListingLanded,
optionCatalog,
sessionId,
updateOptionState,
Expand Down
Loading
Loading