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(); 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} ({ })); 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 993f79c9763..8974fec4248 100644 --- a/apps/mobile/src/components/profiles/new-profile-screen.tsx +++ b/apps/mobile/src/components/profiles/new-profile-screen.tsx @@ -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 { @@ -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'; @@ -108,7 +109,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 9e8111c8bf8..59795408af7 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'; @@ -245,7 +246,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.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' })); diff --git a/apps/mobile/src/components/profiles/profile-commands-screen.tsx b/apps/mobile/src/components/profiles/profile-commands-screen.tsx index ff33b429b94..28e3f985a1e 100644 --- a/apps/mobile/src/components/profiles/profile-commands-screen.tsx +++ b/apps/mobile/src/components/profiles/profile-commands-screen.tsx @@ -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 { @@ -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'; @@ -272,7 +273,7 @@ export function ProfileCommandsScreen({ void leave(); }} /> - {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 ? ( ({ 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' })); 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} diff --git a/apps/mobile/src/components/profiles/repo-bindings-screen.mounted.test.tsx b/apps/mobile/src/components/profiles/repo-bindings-screen.mounted.test.tsx index 9b6fd86ea0a..4a09e90cabf 100644 --- a/apps/mobile/src/components/profiles/repo-bindings-screen.mounted.test.tsx +++ b/apps/mobile/src/components/profiles/repo-bindings-screen.mounted.test.tsx @@ -82,6 +82,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' })); 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 ? (