Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ vi.mock('@/components/agents/use-effective-agent-profile', () => ({
useEffectiveAgentProfile: () => ({
allProfiles: [],
effectiveDefaultId: null,
effectiveDefaultProfile: null,
isLoading: false,
isError: false,
refetch: vi.fn(),
Expand Down
10 changes: 6 additions & 4 deletions apps/mobile/src/app/(app)/agent-chat/profile-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,8 @@ export default function ProfilePickerScreen() {
// "Options expired" flash before a later effect populates state.
const [bridge] = useState(() => profilePickerSlot.get(UNFENCED_ROUTE_KEY));

const { allProfiles, effectiveDefaultId, isLoading, isError, refetch } = useEffectiveAgentProfile(
bridge?.organizationId,
bridge?.selectedOverrideProfileId ?? null
);
const { allProfiles, effectiveDefaultId, effectiveDefaultProfile, isLoading, isError, refetch } =
useEffectiveAgentProfile(bridge?.organizationId, bridge?.selectedOverrideProfileId ?? null);

if (!bridge) {
return (
Expand All @@ -35,6 +33,7 @@ export default function ProfilePickerScreen() {
router.back();
}}
scrollable={false}
topInset="never"
expired
/>
);
Expand All @@ -60,6 +59,9 @@ export default function ProfilePickerScreen() {
// A default exists, so clearing the override is not "no profile": the
// server resolves that default on top of any repository binding.
defaultProfileApplies={effectiveDefaultId !== null}
// Name it, so the row that clears the override says which profile the
// session then runs on instead of only that some default applies.
defaultProfileName={effectiveDefaultProfile?.name ?? null}
isLoading={isLoading}
isError={isError}
needsAttention={picker.overrideNeedsAttention}
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/app/+native-intent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { redirectSystemPath as mapWebPath } from '@/lib/deep-link-handler';
import { handleGlanceableFixturePath } from '@/lib/glanceable/fixture-harness';
import { handleHomeFixturePath } from '@/lib/home-fixture';
import { modelPickerFixtureRoute } from '@/lib/model-picker-fixture';
import { profilePickerFixtureRoute } from '@/lib/profile-picker-fixture';
import { securityAgentSingleRouteFixtureRoute } from '@/lib/security-agent-single-route-fixture';

// Composes the native-intent concerns in order: the share-extension check
Expand Down Expand Up @@ -81,6 +82,11 @@ export function redirectSystemPath({ path, initial }: { path: string; initial: b
if (fixtureRoute) {
return fixtureRoute;
}
// Dev-only profile picker sheet, reached through the development fixture path.
const profilePickerRoute = profilePickerFixtureRoute(path);
if (profilePickerRoute) {
return profilePickerRoute;
}
}
if (handleAppActionPath({ path, initial })) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,20 +178,48 @@ describe('ProfilePickerSheet', () => {
const renderer = mount({
selectedOverrideProfileId: 'backend',
defaultProfileApplies: true,
defaultProfileName: BACKEND.name,
onSelect,
});

// Clearing the override hands the session to the default, so the row says
// which profile that is instead of claiming no profile.
// which profile that is instead of claiming no profile. The name is what
// the Environment chip shows, so the checked row and the chip agree.
expect(radios(renderer).map(node => node.props.accessibilityLabel)).toEqual([
'Default profile',
'Default profile: Backend',
'Backend',
'Frontend',
]);
press(radio(renderer, 'Default profile'));
expect(texts(renderer)).toContain('Default profile');
expect(texts(renderer)).toContain('Resolves to Backend');
press(radio(renderer, 'Default profile: Backend'));
expect(onSelect).toHaveBeenLastCalledWith(null);
});

it('keeps the generic default row when the resolved profile name is unknown', () => {
const renderer = mount({ defaultProfileApplies: true });

expect(radios(renderer).map(node => node.props.accessibilityLabel)).toEqual([
'Default profile',
'Backend',
'Frontend',
]);
expect(texts(renderer)).not.toContainEqual(expect.stringContaining('Resolves to'));
});

it('does not name a default on the No profile row when none applies', () => {
// A name without an applicable default must not leak onto the row that
// clears the override: that row means the session runs on no profile.
const renderer = mount({ defaultProfileName: BACKEND.name });

expect(radios(renderer).map(node => node.props.accessibilityLabel)).toEqual([
'No profile',
'Backend',
'Frontend',
]);
expect(texts(renderer)).not.toContainEqual(expect.stringContaining('Resolves to'));
});

it('leaves every row unchecked and shows the attention copy for a stale override', () => {
const renderer = mount({ selectedOverrideProfileId: 'deleted', needsAttention: true });

Expand Down
51 changes: 43 additions & 8 deletions apps/mobile/src/components/agents/profile-picker-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';

import { EmptyState } from '@/components/empty-state';
import { PickerSheet } from '@/components/picker-sheet';
import { type SheetHeaderTopInset } from '@/components/sheet-header';
import { type SessionProfilePickerProfile } from '@/components/agents/session-profile-picker-model';
import { Button } from '@/components/ui/button';
import { Check, Settings2 } from '@/components/ui/icons';
Expand All @@ -16,6 +17,16 @@ import { cn } from '@/lib/utils';

const SKELETON_ROW_COUNT = 4;

/**
* The sheet presents at the half detent and its full detent stops below the
* status bar, so its header never overlaps the status bar. Reserving that
* inset anyway moved the title and every row by the inset height once the
* header measured its own position after presenting; declaring up front that
* the surface never reaches the window top keeps the first frame identical to
* the resting one.
*/
const SHEET_TOP_INSET: SheetHeaderTopInset = 'never';

export type ProfilePickerSheetProps = {
/** Rows offered as an override: every profile except the repo base. */
candidates: readonly SessionProfilePickerProfile[];
Expand All @@ -29,6 +40,12 @@ export type ProfilePickerSheetProps = {
* runs on, so the row names the default instead of claiming no profile.
*/
defaultProfileApplies: boolean;
/**
* The profile the effective default resolves to, when known. The no-override
* row names it so the checked row matches the Environment chip, which shows
* that same profile's name.
*/
defaultProfileName?: string | null;
isLoading: boolean;
isError: boolean;
/** The picked id no longer resolves to a profile. */
Expand All @@ -49,6 +66,7 @@ export function ProfilePickerSheet({
candidates,
hasProfiles,
defaultProfileApplies,
defaultProfileName,
selectedOverrideProfileId,
isLoading,
isError,
Expand All @@ -66,10 +84,20 @@ export function ProfilePickerSheet({
const noOverrideLabelKey = defaultProfileApplies
? 'profiles.defaultSectionTitle'
: 'agentChat.newSession.noProfile';
const noOverrideLabel = t(noOverrideLabelKey);
// The Environment chip names the profile the default resolves to; the row
// has to say the same thing, or the checked row and the chip disagree.
const resolvedDefaultName = defaultProfileApplies ? (defaultProfileName ?? null) : null;
const noOverrideAccessibilityLabel =
resolvedDefaultName === null
? noOverrideLabel
: t('agentChat.newSession.defaultProfileResolvesToAccessibility', {
name: resolvedDefaultName,
});

if (isLoading) {
return (
<PickerSheet title={title} onDone={onClose} scrollable={false}>
<PickerSheet title={title} onDone={onClose} scrollable={false} topInset={SHEET_TOP_INSET}>
<View className="bg-background">
{Array.from({ length: SKELETON_ROW_COUNT }, (_, index) => (
<View key={index} className={cn(ROW_TARGET_CLASS, 'justify-center px-4 py-3')}>
Expand All @@ -83,7 +111,7 @@ export function ProfilePickerSheet({

if (isError) {
return (
<PickerSheet title={title} onDone={onClose} scrollable={false}>
<PickerSheet title={title} onDone={onClose} scrollable={false} topInset={SHEET_TOP_INSET}>
<EmptyState
icon={Settings2}
title={t('agentChat.newSession.couldNotLoadEnvironment')}
Expand All @@ -105,7 +133,7 @@ export function ProfilePickerSheet({

if (!hasProfiles) {
return (
<PickerSheet title={title} onDone={onClose} scrollable={false}>
<PickerSheet title={title} onDone={onClose} scrollable={false} topInset={SHEET_TOP_INSET}>
<EmptyState
icon={Settings2}
title={title}
Expand All @@ -126,7 +154,7 @@ export function ProfilePickerSheet({
}

return (
<PickerSheet title={title} onDone={onClose} onCancel={onClose}>
<PickerSheet title={title} onDone={onClose} onCancel={onClose} topInset={SHEET_TOP_INSET}>
{needsAttention ? (
<Text className="px-4 pt-3 text-sm text-warn">
{t('agentChat.newSession.configNeedsAttention')}
Expand All @@ -142,13 +170,20 @@ export function ProfilePickerSheet({
onSelect(null);
}}
{...radioItemA11y({
label: t(noOverrideLabelKey),
label: noOverrideAccessibilityLabel,
checked: selectedOverrideProfileId === null && !needsAttention,
})}
>
<Text className="flex-1 text-base text-foreground" numberOfLines={1}>
{t(noOverrideLabelKey)}
</Text>
<View className="min-w-0 flex-1">
<Text className="text-base text-foreground" numberOfLines={1}>
{noOverrideLabel}
</Text>
{resolvedDefaultName === null ? null : (
<Text className="text-sm text-muted-foreground" numberOfLines={1}>
{t('agentChat.newSession.defaultProfileResolvesTo', { name: resolvedDefaultName })}
</Text>
)}
</View>
{selectedOverrideProfileId === null && !needsAttention ? (
<Check size={18} color={colors.primary} />
) : null}
Expand Down
27 changes: 19 additions & 8 deletions apps/mobile/src/components/agents/use-effective-agent-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,23 +58,33 @@ export function useEffectiveAgentProfile(
const isOrg = organizationId !== undefined;
const query = isOrg ? combined : personal;

const { allProfiles, effectiveDefaultId } = useMemo(() => {
const { allProfiles, effectiveDefaultId, effectiveDefaultProfile } = useMemo(() => {
if (isOrg) {
const data = combined.data;
if (!data || !Array.isArray(data.orgProfiles) || !Array.isArray(data.personalProfiles)) {
return { allProfiles: [] as EffectiveAgentProfile[], effectiveDefaultId: null };
return {
allProfiles: [] as EffectiveAgentProfile[],
effectiveDefaultId: null,
effectiveDefaultProfile: null as EffectiveAgentProfile | null,
};
}
const profiles = [...data.orgProfiles, ...data.personalProfiles].map(profile =>
toEffective(profile)
);
return {
allProfiles: [...data.orgProfiles, ...data.personalProfiles].map(profile =>
toEffective(profile)
),
allProfiles: profiles,
effectiveDefaultId: data.effectiveDefaultId,
effectiveDefaultProfile:
profiles.find(profile => profile.id === data.effectiveDefaultId) ?? null,
};
}
const profiles = personal.data ?? [];
const rows = personal.data ?? [];
const profiles = rows.map(profile => toEffective(profile));
const defaultId = rows.find(profile => profile.isDefault)?.id ?? null;
return {
allProfiles: profiles.map(profile => toEffective(profile)),
effectiveDefaultId: profiles.find(profile => profile.isDefault)?.id ?? null,
allProfiles: profiles,
effectiveDefaultId: defaultId,
effectiveDefaultProfile: profiles.find(profile => profile.id === defaultId) ?? null,
};
}, [isOrg, personal.data, combined.data]);

Expand Down Expand Up @@ -102,6 +112,7 @@ export function useEffectiveAgentProfile(
profileId,
allProfiles,
effectiveDefaultId,
effectiveDefaultProfile,
// The user's explicit pick, echoed for the picker's selected row.
selectedProfileId: overrideProfileId ?? null,
hasOverride: query.isError ? false : picker.hasOverride,
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/af.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/am.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/ar.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/az.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/be.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/bg.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/bn.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/bs.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/ca.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/ckb.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/cs.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/mobile/src/i18n/locales/cy.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading