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
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default function ProfileOverviewRoute() {
<ProfileOverviewScreen
profileId={profileId}
organizationId={parseParam(rawOrganizationId) ?? undefined}
stack="profileTab"
/>
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ProfilesListScreen } from '@/components/profiles/profiles-list-screen';

export default function ProfilesListRoute() {
return <ProfilesListScreen />;
return <ProfilesListScreen stack="profileTab" />;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NewProfileScreen } from '@/components/profiles/new-profile-screen';

export default function NewProfileRoute() {
return <NewProfileScreen />;
return <NewProfileScreen stack="profileTab" />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { createElement } from 'react';
import { act, type ReactTestInstance } from '@/test/renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import ProfilePickerScreen from '@/app/(app)/agent-chat/profile-picker';
import { getProfilesPath } from '@/lib/profile-agent-navigation';
import { renderWithProviders } from '@/test/render-with-providers';

const router = vi.hoisted(() => ({ back: vi.fn(), push: vi.fn() }));
const slot = vi.hoisted(() => ({
bridge: undefined as Record<string, unknown> | undefined,
get: vi.fn(),
clear: vi.fn(),
}));

vi.mock('expo-router', () => ({ useRouter: () => router }));

vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));

vi.mock('@/lib/route-registry', () => ({
UNFENCED_ROUTE_KEY: 'unscoped',
useRouteRegistry: () => undefined,
profilePickerSlot: { get: slot.get, clear: slot.clear },
}));

vi.mock('@/components/picker-sheet', () => ({ PickerSheet: 'PickerSheet' }));
vi.mock('@/components/agents/profile-picker-sheet', () => ({
ProfilePickerSheet: 'ProfilePickerSheet',
}));

vi.mock('@/components/agents/use-effective-agent-profile', () => ({
useEffectiveAgentProfile: () => ({
allProfiles: [],
effectiveDefaultId: null,
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
}));

function findSheet(renderer: ReactTestInstance): ReactTestInstance {
const [sheet] = renderer.findAllByType('ProfilePickerSheet' as never);
if (!sheet) {
throw new Error('the profile picker sheet did not render');
}
return sheet;
}

function mount() {
return renderWithProviders(createElement(ProfilePickerScreen));
}

beforeEach(() => {
router.back.mockReset();
router.push.mockReset();
slot.get.mockReset();
slot.clear.mockReset();
slot.get.mockReturnValue({
organizationId: undefined,
selectedOverrideProfileId: null,
repoBindingProfileId: null,
onSelect: vi.fn(),
});
});

describe('ProfilePickerScreen', () => {
it('opens the profile list in the stack that owns the picker, not the Profile tab', async () => {
const { renderer, unmount } = await mount();

act(() => {
(findSheet(renderer.root).props as { onManageProfiles: () => void }).onManageProfiles();
});

// The picker is a root-stack sheet: the list it opens has to join that same
// stack, or Back lands on the Profile tab root instead of the composer.
expect(router.push).toHaveBeenCalledWith(getProfilesPath('app'));
expect(router.back).toHaveBeenCalledTimes(1);
expect(slot.clear).toHaveBeenCalledTimes(1);

unmount();
});
});
9 changes: 5 additions & 4 deletions apps/mobile/src/app/(app)/agent-chat/profile-picker.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { type Href, useRouter } from 'expo-router';
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';

import { ProfilePickerSheet } from '@/components/agents/profile-picker-sheet';
import { resolveSessionProfilePicker } from '@/components/agents/session-profile-picker-model';
import { useEffectiveAgentProfile } from '@/components/agents/use-effective-agent-profile';
import { PickerSheet } from '@/components/picker-sheet';
import { getProfilesPath } from '@/lib/profile-agent-navigation';
import { profilePickerSlot, UNFENCED_ROUTE_KEY, useRouteRegistry } from '@/lib/route-registry';

const PROFILES_HREF = '/(app)/(tabs)/(3_profile)/profiles' as Href;

/**
* The new-session profile picker, presented as the standard native formSheet.
* The bridge carries the current pick and the context; the route re-queries the
Expand Down Expand Up @@ -70,7 +69,9 @@ export default function ProfilePickerScreen() {
}}
onManageProfiles={() => {
close();
router.push(PROFILES_HREF);
// The picker lives in the composer's Stack; the profile list it opens
// must stay there too, or Back would leave the composer behind.
router.push(getProfilesPath('app'));
}}
onRetry={() => {
void refetch();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useLocalSearchParams } from 'expo-router';

import { InvalidRouteState } from '@/components/invalid-route-state';
import { ProfileAgentsScreen } from '@/components/profiles/profile-agents-screen';
import { getProfilesPath } from '@/lib/profile-agent-navigation';
import { parseParam } from '@/lib/route-params';

export default function AppStackProfileAgentsRoute() {
const { profileId: rawProfileId, organizationId: rawOrganizationId } = useLocalSearchParams<{
profileId: string;
organizationId?: string;
}>();
const profileId = parseParam(rawProfileId);
if (!profileId) {
return <InvalidRouteState backTo={getProfilesPath('app')} />;
}
return (
<ProfileAgentsScreen
profileId={profileId}
organizationId={parseParam(rawOrganizationId) ?? undefined}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useLocalSearchParams } from 'expo-router';

import { InvalidRouteState } from '@/components/invalid-route-state';
import { ProfileCommandsScreen } from '@/components/profiles/profile-commands-screen';
import { getProfilesPath } from '@/lib/profile-agent-navigation';
import { parseParam } from '@/lib/route-params';

export default function AppStackProfileCommandsRoute() {
const { profileId: rawProfileId, organizationId: rawOrganizationId } = useLocalSearchParams<{
profileId: string;
organizationId?: string;
}>();
const profileId = parseParam(rawProfileId);
if (!profileId) {
return <InvalidRouteState backTo={getProfilesPath('app')} />;
}
return (
<ProfileCommandsScreen
profileId={profileId}
organizationId={parseParam(rawOrganizationId) ?? undefined}
/>
);
}
24 changes: 24 additions & 0 deletions apps/mobile/src/app/(app)/profile-management/[profileId]/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useLocalSearchParams } from 'expo-router';

import { InvalidRouteState } from '@/components/invalid-route-state';
import { ProfileOverviewScreen } from '@/components/profiles/profile-overview-screen';
import { getProfilesPath } from '@/lib/profile-agent-navigation';
import { parseParam } from '@/lib/route-params';

export default function AppStackProfileOverviewRoute() {
const { profileId: rawProfileId, organizationId: rawOrganizationId } = useLocalSearchParams<{
profileId: string;
organizationId?: string;
}>();
const profileId = parseParam(rawProfileId);
if (!profileId) {
return <InvalidRouteState backTo={getProfilesPath('app')} />;
}
return (
<ProfileOverviewScreen
profileId={profileId}
organizationId={parseParam(rawOrganizationId) ?? undefined}
stack="app"
/>
);
}
23 changes: 23 additions & 0 deletions apps/mobile/src/app/(app)/profile-management/[profileId]/mcp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useLocalSearchParams } from 'expo-router';

import { InvalidRouteState } from '@/components/invalid-route-state';
import { ProfileMcpScreen } from '@/components/profiles/profile-mcp-screen';
import { getProfilesPath } from '@/lib/profile-agent-navigation';
import { parseParam } from '@/lib/route-params';

export default function AppStackProfileMcpRoute() {
const { profileId: rawProfileId, organizationId: rawOrganizationId } = useLocalSearchParams<{
profileId: string;
organizationId?: string;
}>();
const profileId = parseParam(rawProfileId);
if (!profileId) {
return <InvalidRouteState backTo={getProfilesPath('app')} />;
}
return (
<ProfileMcpScreen
profileId={profileId}
organizationId={parseParam(rawOrganizationId) ?? undefined}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useLocalSearchParams } from 'expo-router';

import { InvalidRouteState } from '@/components/invalid-route-state';
import { ProfileSkillsScreen } from '@/components/profiles/profile-skills-screen';
import { getProfilesPath } from '@/lib/profile-agent-navigation';
import { parseParam } from '@/lib/route-params';

export default function AppStackProfileSkillsRoute() {
const { profileId: rawProfileId, organizationId: rawOrganizationId } = useLocalSearchParams<{
profileId: string;
organizationId?: string;
}>();
const profileId = parseParam(rawProfileId);
if (!profileId) {
return <InvalidRouteState backTo={getProfilesPath('app')} />;
}
return (
<ProfileSkillsScreen
profileId={profileId}
organizationId={parseParam(rawOrganizationId) ?? undefined}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useLocalSearchParams } from 'expo-router';

import { InvalidRouteState } from '@/components/invalid-route-state';
import { ProfileKiloCommandsScreen } from '@/components/profiles/profile-kilo-commands-screen';
import { getProfilesPath } from '@/lib/profile-agent-navigation';
import { parseParam } from '@/lib/route-params';

export default function AppStackProfileSlashCommandsRoute() {
const { profileId: rawProfileId, organizationId: rawOrganizationId } = useLocalSearchParams<{
profileId: string;
organizationId?: string;
}>();
const profileId = parseParam(rawProfileId);
if (!profileId) {
return <InvalidRouteState backTo={getProfilesPath('app')} />;
}
return (
<ProfileKiloCommandsScreen
profileId={profileId}
organizationId={parseParam(rawOrganizationId) ?? undefined}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useLocalSearchParams } from 'expo-router';

import { InvalidRouteState } from '@/components/invalid-route-state';
import { ProfileVariablesScreen } from '@/components/profiles/profile-variables-screen';
import { getProfilesPath } from '@/lib/profile-agent-navigation';
import { parseParam } from '@/lib/route-params';

export default function AppStackProfileVariablesRoute() {
const { profileId: rawProfileId, organizationId: rawOrganizationId } = useLocalSearchParams<{
profileId: string;
organizationId?: string;
}>();
const profileId = parseParam(rawProfileId);
if (!profileId) {
return <InvalidRouteState backTo={getProfilesPath('app')} />;
}
return (
<ProfileVariablesScreen
profileId={profileId}
organizationId={parseParam(rawOrganizationId) ?? undefined}
/>
);
}
11 changes: 11 additions & 0 deletions apps/mobile/src/app/(app)/profile-management/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ProfilesListScreen } from '@/components/profiles/profiles-list-screen';

/**
* Profile management opened from the root `(app)` Stack (the new-session form,
* an agent session). Back returns to the screen that opened it instead of the
* Profile tab root; the Profile tab keeps its own copy at
* `(tabs)/(3_profile)/profiles`.
*/
export default function AppStackProfilesListRoute() {
return <ProfilesListScreen stack="app" />;
}
5 changes: 5 additions & 0 deletions apps/mobile/src/app/(app)/profile-management/new.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { NewProfileScreen } from '@/components/profiles/new-profile-screen';

export default function AppStackNewProfileRoute() {
return <NewProfileScreen stack="app" />;
}
11 changes: 11 additions & 0 deletions apps/mobile/src/app/(app)/profile-management/repo-bindings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useLocalSearchParams } from 'expo-router';

import { RepoBindingsScreen } from '@/components/profiles/repo-bindings-screen';
import { parseParam } from '@/lib/route-params';

export default function AppStackRepoBindingsRoute() {
const { organizationId: rawOrganizationId } = useLocalSearchParams<{
organizationId?: string;
}>();
return <RepoBindingsScreen organizationId={parseParam(rawOrganizationId) ?? undefined} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,9 @@ export function NewSessionScreenBody() {
}, [organizationId, overrideProfileId, router]);

const handleOpenRepoDefaults = useCallback(() => {
router.push(getRepoBindingsPath(organizationId));
// The composer's own Stack, so Back returns here with the draft instead of
// landing on the Profile tab root.
router.push(getRepoBindingsPath(organizationId, 'app'));
}, [organizationId, router]);

// Keep the inline selector and picker list in sync.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1177,7 +1177,7 @@ describe('session detail active-profile indicator', () => {
act(() => {
(chip.props.onPress as () => void)();
});
expect(navigationRoutes.at(-1)).toBe('/(app)/(tabs)/(3_profile)/profiles/p1');
expect(navigationRoutes.at(-1)).toBe('/(app)/profile-management/p1');
});

it.each(['user', 'organization'] as const)(
Expand All @@ -1202,7 +1202,7 @@ describe('session detail active-profile indicator', () => {
(chip.props.onPress as () => void)();
});
expect(navigationRoutes.at(-1)).toBe(
`/(app)/(tabs)/(3_profile)/profiles/p1${ownerType === 'organization' ? '?organizationId=org-a' : ''}`
`/(app)/profile-management/p1${ownerType === 'organization' ? '?organizationId=org-a' : ''}`
);
}
);
Expand Down Expand Up @@ -1230,7 +1230,7 @@ describe('session detail active-profile indicator', () => {
act(() => {
(chip.props.onPress as () => void)();
});
expect(navigationRoutes.at(-1)).toBe('/(app)/(tabs)/(3_profile)/profiles/p-recorded');
expect(navigationRoutes.at(-1)).toBe('/(app)/profile-management/p-recorded');
});

it('falls back to the effective default only when the session recorded no profile', async () => {
Expand Down
6 changes: 5 additions & 1 deletion apps/mobile/src/components/agents/session-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -557,10 +557,14 @@ export function SessionDetailContent({
});
const openSessionProfileEditor = () => {
if (sessionProfile) {
// The session screen is on the root `(app)` Stack: the profile editor has
// to join it there, or Back would land on the Profile tab root instead of
// this session.
router.push(
getProfileOverviewPath(
sessionProfile.id,
profileOrganizationId(organizationId, sessionProfile)
profileOrganizationId(organizationId, sessionProfile),
'app'
)
);
}
Expand Down
Loading
Loading