From 8dd833b42f0843e11359185b0fcda9222aaf38af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:20:41 +0000 Subject: [PATCH 1/8] fix(mobile): scroll the profile variables screen in TabScreenScrollView --- .../src/components/profiles/profile-variables-screen.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/components/profiles/profile-variables-screen.tsx b/apps/mobile/src/components/profiles/profile-variables-screen.tsx index 74314d2316f..c100b9145b4 100644 --- a/apps/mobile/src/components/profiles/profile-variables-screen.tsx +++ b/apps/mobile/src/components/profiles/profile-variables-screen.tsx @@ -1,6 +1,6 @@ import { type ReactNode, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ScrollView, View } from 'react-native'; +import { View } from 'react-native'; import { toast } from 'sonner-native'; import { EmptyState } from '@/components/empty-state'; @@ -18,6 +18,7 @@ import { } from '@/components/profiles/profile-variables-rows'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { Button } from '@/components/ui/button'; import { useConfirmDialog } from '@/components/ui/dialog'; import { KeyRound } from '@/components/ui/icons'; @@ -207,7 +208,7 @@ export function ProfileVariablesScreen({ return ( - {content} - + {dialog} From 646d891f7e4092e225a5aefc24b68bc50d23a129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:20:27 +0000 Subject: [PATCH 2/8] test(mobile): mock the tab screen scroller in the variables suite --- .../profiles/profile-variables-screen.mounted.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/src/components/profiles/profile-variables-screen.mounted.test.tsx b/apps/mobile/src/components/profiles/profile-variables-screen.mounted.test.tsx index 05a6f0ce267..b66911eae7d 100644 --- a/apps/mobile/src/components/profiles/profile-variables-screen.mounted.test.tsx +++ b/apps/mobile/src/components/profiles/profile-variables-screen.mounted.test.tsx @@ -57,6 +57,7 @@ vi.mock('@/components/ui/dialog', () => ({ useConfirmDialog: () => ({ confirm: h.confirm, dialog: null }), })); vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); +vi.mock('@/components/tab-screen', () => ({ TabScreenScrollView: 'ScrollView' })); vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); From 69ad12a7c591495b4b51912006a15ab8a31946eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:29:43 +0000 Subject: [PATCH 3/8] test(mobile): exempt the variables mounted suite from the line cap --- .../profiles/profile-variables-screen.mounted.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/src/components/profiles/profile-variables-screen.mounted.test.tsx b/apps/mobile/src/components/profiles/profile-variables-screen.mounted.test.tsx index b66911eae7d..9ff7879f16f 100644 --- a/apps/mobile/src/components/profiles/profile-variables-screen.mounted.test.tsx +++ b/apps/mobile/src/components/profiles/profile-variables-screen.mounted.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- one mounted harness pins the form, secret-masking, and delete flows for the variables screen */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import '@/i18n'; From fe3cb724c76cca410b318400ea3390202d7c8139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:44:11 +0000 Subject: [PATCH 4/8] chore(mobile): retrigger the review check From f8bb5b10d6ee3238751001ece018acc68f3d4a8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:03:09 +0000 Subject: [PATCH 5/8] fix(mobile): scroll the remaining profile tab screens above the tab bar --- .../profiles/new-profile-screen.mounted.test.tsx | 1 + apps/mobile/src/components/profiles/new-profile-screen.tsx | 7 ++++--- .../src/components/profiles/profile-agents-screen.tsx | 7 ++++--- .../profiles/profile-commands-screen.mounted.test.tsx | 1 + .../src/components/profiles/profile-commands-screen.tsx | 7 ++++--- .../components/profiles/profile-kilo-commands-screen.tsx | 7 ++++--- .../profiles/profile-mcp-screen.mounted.test.tsx | 1 + apps/mobile/src/components/profiles/profile-mcp-screen.tsx | 7 ++++--- .../profiles/profile-skills-screen.mounted.test.tsx | 1 + .../src/components/profiles/profile-skills-screen.tsx | 7 ++++--- .../profiles/repo-bindings-screen.mounted.test.tsx | 1 + .../src/components/profiles/repo-bindings-screen.tsx | 7 ++++--- 12 files changed, 33 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/components/profiles/new-profile-screen.mounted.test.tsx b/apps/mobile/src/components/profiles/new-profile-screen.mounted.test.tsx index f225bb16a11..0e364047ddb 100644 --- a/apps/mobile/src/components/profiles/new-profile-screen.mounted.test.tsx +++ b/apps/mobile/src/components/profiles/new-profile-screen.mounted.test.tsx @@ -47,6 +47,7 @@ vi.mock('react-native', () => ({ })); vi.mock('@/components/screen-header', () => ({ ScreenHeader: () => null })); +vi.mock('@/components/tab-screen', () => ({ TabScreenScrollView: 'ScrollView' })); vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); vi.mock('@/components/ui/form-field', () => ({ FormField: 'FormField' })); vi.mock('@/components/ui/segmented-control', () => ({ SegmentedControl: 'SegmentedControl' })); diff --git a/apps/mobile/src/components/profiles/new-profile-screen.tsx b/apps/mobile/src/components/profiles/new-profile-screen.tsx index 53c8c6d0c9b..54a66d99251 100644 --- a/apps/mobile/src/components/profiles/new-profile-screen.tsx +++ b/apps/mobile/src/components/profiles/new-profile-screen.tsx @@ -1,7 +1,7 @@ import { useRouter } from 'expo-router'; import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ScrollView, View } from 'react-native'; +import { View } from 'react-native'; import { toast } from 'sonner-native'; import { @@ -11,6 +11,7 @@ import { type ProfileOwnerChoice, } from '@/components/profiles/profile-owner-model'; import { ScreenHeader } from '@/components/screen-header'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { Button } from '@/components/ui/button'; import { FormField } from '@/components/ui/form-field'; import { SegmentedControl } from '@/components/ui/segmented-control'; @@ -95,7 +96,7 @@ export function NewProfileScreen() { return ( - {t('profiles.createAction')} - + ); } diff --git a/apps/mobile/src/components/profiles/profile-agents-screen.tsx b/apps/mobile/src/components/profiles/profile-agents-screen.tsx index 00d909cf7e6..917491dde98 100644 --- a/apps/mobile/src/components/profiles/profile-agents-screen.tsx +++ b/apps/mobile/src/components/profiles/profile-agents-screen.tsx @@ -1,6 +1,6 @@ import { type ReactNode, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Pressable, ScrollView, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { toast } from 'sonner-native'; import { EmptyState } from '@/components/empty-state'; @@ -8,6 +8,7 @@ import { AgentFormSheet } from '@/components/profiles/agent-form-sheet'; import { agentRows, type AgentSource } from '@/components/profiles/profile-agents-model'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { Button } from '@/components/ui/button'; import { useConfirmDialog } from '@/components/ui/dialog'; import { Bot, Pencil, Trash2 } from '@/components/ui/icons'; @@ -229,7 +230,7 @@ export function ProfileAgentsScreen({ return ( - {content} - + {form !== null ? ( ({ useThemeColors: () => ({ mutedForeground: '#000000', destructive: '#FF0000' }), })); vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); +vi.mock('@/components/tab-screen', () => ({ TabScreenScrollView: 'ScrollView' })); vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); diff --git a/apps/mobile/src/components/profiles/profile-commands-screen.tsx b/apps/mobile/src/components/profiles/profile-commands-screen.tsx index def7cc6edf3..96c053f1fc0 100644 --- a/apps/mobile/src/components/profiles/profile-commands-screen.tsx +++ b/apps/mobile/src/components/profiles/profile-commands-screen.tsx @@ -1,6 +1,6 @@ import { type ReactNode, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Pressable, ScrollView, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { toast } from 'sonner-native'; import { EmptyState } from '@/components/empty-state'; @@ -15,6 +15,7 @@ import { } from '@/components/profiles/profile-commands-model'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { Button } from '@/components/ui/button'; import { FormField } from '@/components/ui/form-field'; import { ChevronDown, ChevronUp, Terminal, Trash2 } from '@/components/ui/icons'; @@ -264,7 +265,7 @@ export function ProfileCommandsScreen({ return ( - {content} - + ); } diff --git a/apps/mobile/src/components/profiles/profile-kilo-commands-screen.tsx b/apps/mobile/src/components/profiles/profile-kilo-commands-screen.tsx index ec7210cee00..de318163b92 100644 --- a/apps/mobile/src/components/profiles/profile-kilo-commands-screen.tsx +++ b/apps/mobile/src/components/profiles/profile-kilo-commands-screen.tsx @@ -1,6 +1,6 @@ import { type ReactNode, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ScrollView, View } from 'react-native'; +import { View } from 'react-native'; import { toast } from 'sonner-native'; import { EmptyState } from '@/components/empty-state'; @@ -17,6 +17,7 @@ import { } from '@/components/profiles/profile-kilo-commands-model'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { Button } from '@/components/ui/button'; import { useConfirmDialog } from '@/components/ui/dialog'; import { Terminal } from '@/components/ui/icons'; @@ -222,7 +223,7 @@ export function ProfileKiloCommandsScreen({ return ( - {content} - + {form !== null ? ( ({ })); vi.mock('@/components/sheet-header', () => ({ SheetHeader: 'SheetHeader' })); vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); +vi.mock('@/components/tab-screen', () => ({ TabScreenScrollView: 'ScrollView' })); vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); diff --git a/apps/mobile/src/components/profiles/profile-mcp-screen.tsx b/apps/mobile/src/components/profiles/profile-mcp-screen.tsx index 7eae98ae97e..98e9be4db45 100644 --- a/apps/mobile/src/components/profiles/profile-mcp-screen.tsx +++ b/apps/mobile/src/components/profiles/profile-mcp-screen.tsx @@ -1,6 +1,6 @@ import { type ReactNode, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Pressable, ScrollView, Switch, View } from 'react-native'; +import { Pressable, Switch, View } from 'react-native'; import { toast } from 'sonner-native'; import { EmptyState } from '@/components/empty-state'; @@ -12,6 +12,7 @@ import { } from '@/components/profiles/profile-mcp-model'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { Button } from '@/components/ui/button'; import { useConfirmDialog } from '@/components/ui/dialog'; import { Lock, Pencil, Server, Trash2 } from '@/components/ui/icons'; @@ -245,7 +246,7 @@ export function ProfileMcpScreen({ return ( - {content} - + {form !== null ? ( ({ })); vi.mock('@/components/sheet-header', () => ({ SheetHeader: 'SheetHeader' })); vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); +vi.mock('@/components/tab-screen', () => ({ TabScreenScrollView: 'ScrollView' })); vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); diff --git a/apps/mobile/src/components/profiles/profile-skills-screen.tsx b/apps/mobile/src/components/profiles/profile-skills-screen.tsx index 17f2d3f777e..9adab74fa4c 100644 --- a/apps/mobile/src/components/profiles/profile-skills-screen.tsx +++ b/apps/mobile/src/components/profiles/profile-skills-screen.tsx @@ -1,6 +1,6 @@ import { type ReactNode, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Pressable, ScrollView, Switch, View } from 'react-native'; +import { Pressable, Switch, View } from 'react-native'; import { toast } from 'sonner-native'; import { EmptyState } from '@/components/empty-state'; @@ -8,6 +8,7 @@ import { skillRowSubtitle } from '@/components/profiles/skill-markdown'; import { SkillFormSheet, type SkillFormSubmission } from '@/components/profiles/skill-form-sheet'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { Button } from '@/components/ui/button'; import { useConfirmDialog } from '@/components/ui/dialog'; import { Pencil, Sparkles, Trash2 } from '@/components/ui/icons'; @@ -224,7 +225,7 @@ export function ProfileSkillsScreen({ return ( - {content} - + {form !== null ? ( ({ })); vi.mock('@/components/sheet-header', () => ({ SheetHeader: 'SheetHeader' })); vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); +vi.mock('@/components/tab-screen', () => ({ TabScreenScrollView: 'ScrollView' })); vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); diff --git a/apps/mobile/src/components/profiles/repo-bindings-screen.tsx b/apps/mobile/src/components/profiles/repo-bindings-screen.tsx index 0f6cc95bd83..1b8d64b5ba8 100644 --- a/apps/mobile/src/components/profiles/repo-bindings-screen.tsx +++ b/apps/mobile/src/components/profiles/repo-bindings-screen.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Pressable, ScrollView, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { toast } from 'sonner-native'; import { EmptyState } from '@/components/empty-state'; @@ -14,6 +14,7 @@ import { } from '@/components/profiles/repo-bindings-model'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { Button } from '@/components/ui/button'; import { ChevronDown, GitBranch, Trash2 } from '@/components/ui/icons'; import { Skeleton } from '@/components/ui/skeleton'; @@ -190,7 +191,7 @@ export function RepoBindingsScreen({ organizationId }: Readonly<{ organizationId return ( - {t('profiles.repoBindings.add')} )} - + {repoPickerOpen ? ( Date: Wed, 21 Oct 2026 06:47:19 +0000 Subject: [PATCH 6/8] fix(mobile): keep the review memory list clear of the tab bar The proposal list now ends with the shared tab bar clearance, matching the review list and the other list screens, so the last proposal scrolls clear of the bar in both orientations. --- .../code-reviewer/review-memory-screen.mounted.test.tsx | 2 ++ .../components/code-reviewer/review-memory-screen.tsx | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/components/code-reviewer/review-memory-screen.mounted.test.tsx b/apps/mobile/src/components/code-reviewer/review-memory-screen.mounted.test.tsx index 33aa35f43c7..0c7bfe5320d 100644 --- a/apps/mobile/src/components/code-reviewer/review-memory-screen.mounted.test.tsx +++ b/apps/mobile/src/components/code-reviewer/review-memory-screen.mounted.test.tsx @@ -141,6 +141,8 @@ vi.mock('@/components/query-error', () => ({ vi.mock('@/components/screen-header', () => ({ ScreenHeader: () => null })); +vi.mock('@/components/tab-screen', () => ({ useTabBarBottomPadding: () => 0 })); + vi.mock('@/components/ui/button', () => ({ Button: (props: { children?: unknown; onPress?: () => void; accessibilityLabel?: string }) => { buttons.rendered.push(props); diff --git a/apps/mobile/src/components/code-reviewer/review-memory-screen.tsx b/apps/mobile/src/components/code-reviewer/review-memory-screen.tsx index ff572669941..fd7edd8b0f9 100644 --- a/apps/mobile/src/components/code-reviewer/review-memory-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/review-memory-screen.tsx @@ -9,6 +9,7 @@ import { CenteredState } from '@/components/centered-state'; import { EmptyState } from '@/components/empty-state'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; +import { useTabBarBottomPadding } from '@/components/tab-screen'; import { Button } from '@/components/ui/button'; import { Brain } from '@/components/ui/icons'; import { Skeleton } from '@/components/ui/skeleton'; @@ -32,6 +33,7 @@ export function ReviewMemoryScreen({ scope }: Readonly<{ scope: string }>) { const trpc = useTRPC(); const { t } = useTranslation(); const colors = useThemeColors(); + const paddingBottom = useTabBarBottomPadding(); const ownerInput = reviewMemoryOwnerInput(scope); const permission = useReviewerPermission(scope); @@ -180,7 +182,12 @@ export function ReviewMemoryScreen({ scope }: Readonly<{ scope: string }>) { )} - ListFooterComponent={footer} + ListFooterComponent={ + <> + {footer} + + + } onEndReached={() => { if (proposalsQuery.hasNextPage && !proposalsQuery.isFetchingNextPage) { void proposalsQuery.fetchNextPage(); From 92879f5748fa61c0ea285e80afc2333f24b254cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:53:45 +0000 Subject: [PATCH 7/8] test(mobile): mock the tab screen scroller in the command save suite --- .../profiles/profile-commands-screen.save.mounted.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/src/components/profiles/profile-commands-screen.save.mounted.test.tsx b/apps/mobile/src/components/profiles/profile-commands-screen.save.mounted.test.tsx index c04b2337fb5..fee63bb3f89 100644 --- a/apps/mobile/src/components/profiles/profile-commands-screen.save.mounted.test.tsx +++ b/apps/mobile/src/components/profiles/profile-commands-screen.save.mounted.test.tsx @@ -45,6 +45,7 @@ vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({ mutedForeground: '#000000', destructive: '#FF0000' }), })); vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); +vi.mock('@/components/tab-screen', () => ({ TabScreenScrollView: 'ScrollView' })); vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); From 409484afafb2d44473cd09d362a65592189b1c80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:12:00 +0000 Subject: [PATCH 8/8] fix(mobile): keep the passkeys empty state above the fold in landscape The passkeys screen scrolls its body above a footer that already reserves the tab bar, so in a phone held sideways the body band is shorter than the empty state's icon bubble plus its copy: only the key icon rendered and the title and hint started below the fold. The screen now renders the compact empty state there, and a compact top-pinned state drops the fixed top air. --- apps/mobile/src/components/empty-state.tsx | 18 +++--- ...passkeys-screen.landscape.mounted.test.tsx | 62 +++++++++++++++++++ .../passkeys-screen.test-helpers.ts | 6 +- .../mobile/src/components/passkeys-screen.tsx | 9 ++- 4 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/src/components/passkeys-screen.landscape.mounted.test.tsx diff --git a/apps/mobile/src/components/empty-state.tsx b/apps/mobile/src/components/empty-state.tsx index e9e461f9091..381295753ff 100644 --- a/apps/mobile/src/components/empty-state.tsx +++ b/apps/mobile/src/components/empty-state.tsx @@ -29,11 +29,12 @@ type EmptyStateProps = { iconStrokeWidth?: number; /** Set to 'header' when the title acts as the screen's heading (QueryError does). */ titleAccessibilityRole?: 'header'; - /** Renders the short presentation (no icon bubble, tighter block gaps) for a - * caller whose clear region cannot hold the full height; the title, the - * description and the action all stay. When omitted, the centered form - * reads the band the `CenteredState` around it publishes and compacts on its - * own, so the form cannot disagree with the reserve the caller set. */ + /** Renders the short presentation (no icon bubble, tighter block gaps, and + * no top pin air above a `top`-placed stack) for a caller whose clear region + * cannot hold the full height; the title, the description and the action all + * stay. When omitted, the centered form reads the band the `CenteredState` + * around it publishes and compacts on its own, so the form cannot disagree + * with the reserve the caller set. */ compact?: boolean; }; @@ -74,7 +75,10 @@ function EmptyStateBody({ // scroll brings them back. The bubble is decoration: a short band drops it // and halves the gaps, which keeps the copy and the action inside the band // with no scroll. `placement="top"` and `placement="static"` states are laid - // out by their own caller and keep the full stack. + // out by their own caller and keep the full stack, unless the caller says + // the band is short: then the top pin's fixed air goes too, because 64pt of + // it is a band's whole height when a footer above the tab bar already owns + // the rest of a landscape window. // // A caller that measured its own clear region passes `compact` and owns the // decision (the Agents screen does); otherwise the centered form reads the @@ -87,7 +91,7 @@ function EmptyStateBody({ className={cn( 'items-center px-6', compact ? 'gap-2' : 'gap-4', - placement === 'top' && 'pt-16', + placement === 'top' && !compact && 'pt-16', className )} > diff --git a/apps/mobile/src/components/passkeys-screen.landscape.mounted.test.tsx b/apps/mobile/src/components/passkeys-screen.landscape.mounted.test.tsx new file mode 100644 index 00000000000..62e9530600d --- /dev/null +++ b/apps/mobile/src/components/passkeys-screen.landscape.mounted.test.tsx @@ -0,0 +1,62 @@ +import { + first, + hasButtonLabel, + list, + mount, + nodes, + store, + windowSize, +} from './passkeys-screen.test-helpers'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { waitFor } from '@/test/render-with-providers'; + +beforeEach(() => { + vi.clearAllMocks(); + windowSize.width = 411; + windowSize.height = 914; + store.rows = []; + list.supported.mockReturnValue(true); + list.queryFn.mockImplementation(() => ({ success: true, passkeys: store.rows })); +}); + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe('PasskeysScreen empty state in a short window', () => { + it('keeps the icon bubble above the copy in portrait', async () => { + const view = await mount(); + await waitFor(() => nodes(view, 'EmptyState').length === 1); + + const empty = first(view, 'EmptyState'); + expect(empty.props.compact).toBe(false); + expect(empty.props.title).toBe('No passkeys yet'); + expect(hasButtonLabel(view, 'Add a passkey')).toBe(true); + view.unmount(); + }); + + it('drops the decoration when the window is a phone held sideways', async () => { + // The footer above the tab bar owns most of a landscape window, so the + // empty state's band is shorter than the bubble plus the copy: the title + // and the hint would start below the fold. + windowSize.width = 914; + windowSize.height = 411; + const view = await mount(); + await waitFor(() => nodes(view, 'EmptyState').length === 1); + + const empty = first(view, 'EmptyState'); + expect(empty.props.compact).toBe(true); + expect(empty.props.title).toBe('No passkeys yet'); + expect(empty.props.description).toBe('Add a passkey to sign in without a password.'); + expect(hasButtonLabel(view, 'Add a passkey')).toBe(true); + view.unmount(); + }); + + it('keeps the full stack for a tablet in landscape', async () => { + windowSize.width = 1024; + windowSize.height = 600; + const view = await mount(); + await waitFor(() => nodes(view, 'EmptyState').length === 1); + + expect(first(view, 'EmptyState').props.compact).toBe(false); + view.unmount(); + }); +}); diff --git a/apps/mobile/src/components/passkeys-screen.test-helpers.ts b/apps/mobile/src/components/passkeys-screen.test-helpers.ts index a391808eb32..dd38b74442f 100644 --- a/apps/mobile/src/components/passkeys-screen.test-helpers.ts +++ b/apps/mobile/src/components/passkeys-screen.test-helpers.ts @@ -52,12 +52,15 @@ const list = vi.hoisted(() => ({ supported: vi.fn(() => true), })); const store = vi.hoisted(() => ({ rows: [] as unknown[] })); +/** The window the screen lays out in; a test sets a landscape size to read the + * short-window presentation and restores it afterwards. */ +const windowSize = vi.hoisted(() => ({ width: 411, height: 914 })); const toastSuccess = vi.hoisted(() => vi.fn()); const toastError = vi.hoisted(() => vi.fn()); const confirmSpy = vi.hoisted(() => vi.fn()); // `vi.hoisted` results cannot be exported at their declaration (vitest hoists // them above the export), so they are exported after the declaration instead. -export { confirmSpy, list, store, toastError, toastSuccess }; +export { confirmSpy, list, store, toastError, toastSuccess, windowSize }; /** * The confirmation the stubbed `useConfirmDialog` captured; mirrors the @@ -101,6 +104,7 @@ vi.mock('react-native', () => ({ Pressable: 'Pressable', ScrollView: 'ScrollView', TextInput: 'TextInput', + useWindowDimensions: () => windowSize, View: 'View', })); vi.mock('@/lib/hooks/use-theme-colors', () => ({ diff --git a/apps/mobile/src/components/passkeys-screen.tsx b/apps/mobile/src/components/passkeys-screen.tsx index d9a2e871705..4dbc2406cd6 100644 --- a/apps/mobile/src/components/passkeys-screen.tsx +++ b/apps/mobile/src/components/passkeys-screen.tsx @@ -1,7 +1,7 @@ import { hashKey, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { type ReactNode, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ScrollView, View } from 'react-native'; +import { ScrollView, useWindowDimensions, View } from 'react-native'; import { toast } from 'sonner-native'; import { EmptyState } from '@/components/empty-state'; @@ -21,6 +21,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { useAuth } from '@/lib/auth/auth-context'; import { passkeysSupported, registerPasskey } from '@/lib/auth/passkey-client'; +import { isShortViewport } from '@/lib/centered-state-layout'; import { isLatestMutationGeneration, nextMutationGeneration, @@ -95,6 +96,11 @@ export function PasskeysScreen() { const colors = useThemeColors(); const { token } = useAuth(); const tabBarBottomPadding = useTabBarBottomPadding(); + // The body sits between the header and a footer that already reserves the + // tab bar, so in a phone held sideways its band is only a few dozen points + // tall and the full stack starts below the fold. Centered states learn this + // from their measured band; this plain body asks the window instead. + const windowSize = useWindowDimensions(); const listKey = trpc.user.getPasskeys.queryKey(); // Stable per-cache key for the removal generations: overlapping removals must @@ -353,6 +359,7 @@ export function PasskeysScreen() { {failureNotice}