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}