Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);

Expand Down Expand Up @@ -180,7 +182,12 @@ export function ReviewMemoryScreen({ scope }: Readonly<{ scope: string }>) {
</Text>
</View>
)}
ListFooterComponent={footer}
ListFooterComponent={
<>
{footer}
<View style={{ height: paddingBottom }} pointerEvents="none" />
</>
}
onEndReached={() => {
if (proposalsQuery.hasNextPage && !proposalsQuery.isFetchingNextPage) {
void proposalsQuery.fetchNextPage();
Expand Down
18 changes: 11 additions & 7 deletions apps/mobile/src/components/empty-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -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
Expand All @@ -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
)}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
6 changes: 5 additions & 1 deletion apps/mobile/src/components/passkeys-screen.test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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', () => ({
Expand Down
9 changes: 8 additions & 1 deletion apps/mobile/src/components/passkeys-screen.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -353,6 +359,7 @@ export function PasskeysScreen() {
{failureNotice}
<EmptyState
placement="top"
compact={isShortViewport(windowSize.width, windowSize.height)}
icon={KeyRound}
title={t('profile.passkeysEmpty')}
description={canCreatePasskeys ? t('profile.passkeysEmptyHint') : addFailureNotice}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,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' }));
Expand Down
7 changes: 4 additions & 3 deletions apps/mobile/src/components/profiles/new-profile-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { canManageOrganization } from '@kilocode/app-shared/organizations';
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 {
Expand All @@ -12,6 +12,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';
Expand Down Expand Up @@ -108,7 +109,7 @@ export function NewProfileScreen() {
return (
<View className="flex-1 bg-background">
<ScreenHeader title={t('profiles.newProfile')} />
<ScrollView
<TabScreenScrollView
className="flex-1"
contentContainerClassName="gap-4 px-6 pt-4 pb-8"
keyboardShouldPersistTaps="handled"
Expand Down Expand Up @@ -171,7 +172,7 @@ export function NewProfileScreen() {
>
<Text>{t('profiles.createAction')}</Text>
</Button>
</ScrollView>
</TabScreenScrollView>
</View>
);
}
7 changes: 4 additions & 3 deletions apps/mobile/src/components/profiles/profile-agents-screen.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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';
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';
Expand Down Expand Up @@ -245,15 +246,15 @@ export function ProfileAgentsScreen({
return (
<View className="flex-1 bg-background">
<ScreenHeader title={t('profiles.agents.title')} />
<ScrollView
<TabScreenScrollView
className="flex-1"
contentContainerClassName="gap-3 px-6 pt-4 pb-8"
keyboardShouldPersistTaps="handled"
automaticallyAdjustKeyboardInsets
showsVerticalScrollIndicator={false}
>
{content}
</ScrollView>
</TabScreenScrollView>

{form !== null ? (
<AgentFormSheet
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,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' }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' }));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useRouter } from 'expo-router';
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 { EmptyState } from '@/components/empty-state';
import {
Expand All @@ -18,6 +18,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';
Expand Down Expand Up @@ -272,15 +273,15 @@ export function ProfileCommandsScreen({
void leave();
}}
/>
<ScrollView
<TabScreenScrollView
className="flex-1"
contentContainerClassName="gap-3 px-6 pt-4 pb-8"
keyboardShouldPersistTaps="handled"
automaticallyAdjustKeyboardInsets
showsVerticalScrollIndicator={false}
>
{content}
</ScrollView>
</TabScreenScrollView>
</View>
);
}
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -222,15 +223,15 @@ export function ProfileKiloCommandsScreen({
return (
<View className="flex-1 bg-background">
<ScreenHeader title={t('profiles.slashCommands.title')} />
<ScrollView
<TabScreenScrollView
className="flex-1"
contentContainerClassName="gap-3 px-6 pt-4 pb-8"
keyboardShouldPersistTaps="handled"
automaticallyAdjustKeyboardInsets
showsVerticalScrollIndicator={false}
>
{content}
</ScrollView>
</TabScreenScrollView>

{form !== null ? (
<KiloCommandFormSheet
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ vi.mock('@/components/agents/session-page-sheet', () => ({
}));
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' }));
Expand Down
Loading
Loading