diff --git a/apps/mobile/src/components/security-agent/notification-settings-screen.tsx b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx
index 2ce9fd9b772..f33b693db5f 100644
--- a/apps/mobile/src/components/security-agent/notification-settings-screen.tsx
+++ b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx
@@ -208,7 +208,15 @@ export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string }
// iOS IME inset and focused-field auto-reveal (AGENTS.md: input screens).
automaticallyAdjustKeyboardInsets
className="flex-1"
- contentContainerClassName="px-6 gap-6 pt-4"
+ // The personal scope has no section heading between its two switches, so
+ // the break between the two groups carries their separation: it has to
+ // read as a group break, not as the 10dp step down to the severity label
+ // or the 7dp step inside its options, and not as the void a removed
+ // heading would leave. The organization scope keeps the standard gap,
+ // next to its headings. The permission note above the groups only
+ // renders for a scope that cannot manage, which the personal scope
+ // never is.
+ contentContainerClassName={cn('px-6 pt-4', personal ? 'gap-7' : 'gap-6')}
>
{!canManage && (
@@ -216,9 +224,11 @@ export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string }
)}
-
- {t('securityAgent.notifications.newFindingSection')}
-
+ {!personal && (
+
+ {t('securityAgent.notifications.newFindingSection')}
+
+ )}
-
- {t('securityAgent.notifications.slaSection')}
-
+ {!personal && (
+
+ {t('securityAgent.notifications.slaSection')}
+
+ )}
({ randomUUID: () => '00000000-0000-4000-8000-000000000000' }));
+vi.mock('expo-haptics', () => ({ selectionAsync: vi.fn() }));
+vi.mock('react-native', () => ({
+ View: 'View',
+ ScrollView: 'ScrollView',
+ TextInput: 'TextInput',
+ Pressable: 'Pressable',
+ Switch: 'Switch',
+ Platform: { OS: 'android' },
+ useColorScheme: () => 'light',
+ Alert: { alert: vi.fn() },
+ I18nManager: { isRTL: false },
+}));
+vi.mock('expo-router', () => ({
+ useRouter: () => ({ back: vi.fn(), replace: vi.fn(), push: vi.fn() }),
+ useNavigation: () => ({ dispatch: vi.fn(), goBack: vi.fn() }),
+ usePreventRemove: () => undefined,
+ // The screen's disabled-config redirect only runs on focus, and the config
+ // these cases mount is enabled, so it never redirects here.
+ useFocusEffect: () => undefined,
+}));
+vi.mock('sonner-native', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+vi.mock('@/components/ui/icons', () => ({ Check: 'Check' }));
+vi.mock('@/lib/hooks/use-theme-colors', () => ({
+ useThemeColors: () => ({
+ mutedForeground: '#000',
+ primaryForeground: '#fff',
+ primary: '#00f',
+ }),
+}));
+vi.mock('@/lib/hooks/use-security-agent', () => ({
+ useSecurityAgentCapability: () => ({
+ canManage: true,
+ status: 'allowed',
+ isLoading: false,
+ isFetching: false,
+ isError: false,
+ refetch: vi.fn(),
+ }),
+ useSecurityAgentConfig: () => ({
+ data: {
+ isEnabled: true,
+ newFindingNotificationsEnabled: true,
+ newFindingNotificationMinSeverity: 'high',
+ slaNotificationsEnabled: true,
+ slaNotificationMinSeverity: 'medium',
+ slaNotificationWarningDays: 7,
+ },
+ isLoading: false,
+ isError: false,
+ refetch: vi.fn(),
+ }),
+ useSaveSecurityAgentConfig: () => ({ mutateAsync: vi.fn(), isPending: false }),
+ useTrackSecurityAgentInteraction: () => ({ mutate: vi.fn() }),
+}));
+vi.mock('@rn-primitives/dialog', () => ({
+ Action: 'AlertDialog.Action',
+ Cancel: 'AlertDialog.Cancel',
+ Content: 'AlertDialog.Content',
+ Description: 'AlertDialog.Description',
+ Overlay: 'AlertDialog.Overlay',
+ Portal: 'AlertDialog.Portal',
+ Root: 'AlertDialog.Root',
+ Title: 'AlertDialog.Title',
+}));
+vi.mock('@/components/destructive-confirm-dialog', () => ({
+ DestructiveConfirmDialog: () => null,
+}));
+vi.mock('@/components/ui/input', () => ({ Input: 'Input' }));
+vi.mock('@/components/ui/skeleton', () => ({ Skeleton: () => null }));
+vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));
+vi.mock('@/components/ui/radio-group', () => ({
+ RadioGroup: (props: { children?: unknown }) => props.children ?? null,
+ radioItemA11y: ({
+ label,
+ checked,
+ disabled,
+ }: {
+ label: string;
+ checked: boolean;
+ disabled: boolean;
+ }) => ({
+ accessibilityRole: 'radio',
+ accessibilityLabel: label,
+ accessibilityState: { checked, disabled, busy: false },
+ }),
+}));
+vi.mock('@/components/screen-header', () => ({ ScreenHeader: () => null }));
+vi.mock('@/components/platform-error-screen', () => ({
+ PlatformErrorScreen: 'PlatformErrorScreen',
+}));
+vi.mock('@/components/security-agent/settings-save-button', () => ({
+ SettingsSaveButton: () => null,
+}));
+vi.mock('@/components/tab-screen', () => ({ TabScreenScrollView: 'TabScreenScrollView' }));
+
+const PERSONAL_SCOPE = 'personal';
+const ORGANIZATION_SCOPE = '00000000-0000-4000-8000-000000000001';
+
+let renderer: TestRenderer.ReactTestRenderer | undefined = undefined;
+
+afterEach(() => {
+ act(() => {
+ renderer?.unmount();
+ });
+ renderer = undefined;
+});
+
+function mount(scope: string): TestRenderer.ReactTestRenderer {
+ act(() => {
+ renderer = TestRenderer.create(createElement(NotificationSettingsScreen, { scope }));
+ });
+ if (!renderer) {
+ throw new Error('renderer was not created');
+ }
+ return renderer;
+}
+
+/**
+ * The section headings the tree paints, in order. A switch's accessible label
+ * is composed from the same strings on separate lines, so it cannot stand in
+ * for the heading itself.
+ */
+function sectionHeadings(screen: TestRenderer.ReactTestRenderer): string[] {
+ const sectionKeys = new Set([
+ i18n.t('securityAgent.notifications.newFindingSection'),
+ i18n.t('securityAgent.notifications.slaSection'),
+ ]);
+ return screen.root
+ .findAllByType('Text')
+ .map(node => node.props.children)
+ .filter((child): child is string => typeof child === 'string' && sectionKeys.has(child));
+}
+
+describe('personal scope', () => {
+ it('titles each switch after its notification and shows no section heading', () => {
+ const screen = mount(PERSONAL_SCOPE);
+ const copy = screen.root.findAllByType('Text').map(node => node.props.children);
+
+ expect(copy).toContain(i18n.t('securityAgent.notifications.newFindingPersonalTitle'));
+ expect(copy).toContain(i18n.t('securityAgent.notifications.slaPersonalTitle'));
+ expect(sectionHeadings(screen)).toEqual([]);
+ });
+
+ it('separates the two switch groups by a break wider than the steps inside one', () => {
+ const screen = mount(PERSONAL_SCOPE);
+
+ expect(screen.root.findByType('TabScreenScrollView').props.contentContainerClassName).toBe(
+ 'px-6 pt-4 gap-7'
+ );
+ });
+});
+
+describe('organization scope', () => {
+ it('heads each switch with its notification section, because both read the same recipient', () => {
+ const screen = mount(ORGANIZATION_SCOPE);
+ const names = screen.root.findAllByType('Switch').map(node => node.props.accessibilityLabel);
+
+ expect(names).toHaveLength(2);
+ expect(names[0]).toContain(i18n.t('securityAgent.notifications.newFindingSection'));
+ expect(names[1]).toContain(i18n.t('securityAgent.notifications.slaSection'));
+ expect(names[1]).not.toContain(i18n.t('securityAgent.notifications.newFindingSection'));
+ expect(sectionHeadings(screen)).toEqual([
+ i18n.t('securityAgent.notifications.newFindingSection'),
+ i18n.t('securityAgent.notifications.slaSection'),
+ ]);
+ });
+
+ it('keeps the standard group gap, next to its headings', () => {
+ const screen = mount(ORGANIZATION_SCOPE);
+
+ expect(screen.root.findByType('TabScreenScrollView').props.contentContainerClassName).toBe(
+ 'px-6 pt-4 gap-6'
+ );
+ });
+});