diff --git a/apps/mobile/plugins/branded-splash.test.ts b/apps/mobile/plugins/branded-splash.test.ts
index 2cbd03b8eb..1c80dd176d 100644
--- a/apps/mobile/plugins/branded-splash.test.ts
+++ b/apps/mobile/plugins/branded-splash.test.ts
@@ -124,6 +124,10 @@ describe('shared branded splash', () => {
});
it('generates both native splash surfaces from the same options', async () => {
+ // Compile against a throwaway project root like the sibling cases: reading
+ // the developer's generated `android/` would fold its existing colors into
+ // the introspection result and make this assertion depend on local state.
+ const { root } = createAndroidProject();
const config: ExportedConfig = withBrandedSplash(
{ name: 'Kilo', slug: 'kilo-app', _internal: { projectRoot } },
{ image: './assets/images/logo-mark.png', backgroundColor: '#FAF74F', imageWidth: 100 }
@@ -140,7 +144,6 @@ describe('shared branded splash', () => {
// it at this package's root would read a developer's prebuilt `android/`
// tree — its `colors.xml` is absent in CI — and merge colors this test does
// not own into the mod results, making the assertion machine-dependent.
- const { root } = createAndroidProject();
const evaluated = await compileModsAsync(config, {
projectRoot: root,
platforms: ['ios', 'android'],
diff --git a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx
index 063f80b200..ab11b214f5 100644
--- a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx
@@ -22,6 +22,7 @@ import {
shouldShowNeedsInput,
useSessionAttentionRevision,
} from '@/lib/session-attention';
+import { TabBarLabelContext } from '@/lib/tab-bar-clearance';
import {
getEffectiveTabBarHeight,
getTabBarHorizontalInset,
@@ -60,21 +61,46 @@ export default function TabsLayout() {
const segments = useSegments();
const colors = useThemeColors();
const { bottom, left, right } = useSafeAreaInsets();
- const { fontScale } = useWindowDimensions();
+ const { width, fontScale } = useWindowDimensions();
const hideTabs = shouldHideTabBar(pathname);
- const showTabLabel = shouldShowTabLabel(fontScale);
+ const showKiloClawTab = useKiloClawTabVisible();
+ const showQuickChatTab = useFeatureFlag(FEATURE_FLAG_QUICK_CHAT, false);
+ const tabFlags = { showKiloClaw: showKiloClawTab, showQuickChat: showQuickChatTab };
+ const tabCount = visibleTabCount(showKiloClawTab, showQuickChatTab);
+ // The label box is the tab item minus the bar's side safe areas and the
+ // tab item's own padding (subtracted inside `tabLabelFits`). A window whose
+ // width was never measured leaves the box unknown — the same missing
+ // measurement `shouldStackHeaderActions` treats as its default — so the
+ // width rule stays out of it and the font-scale rule alone decides.
+ const tabBarContentWidth = width - left - right;
+ const tabItemWidth = Number.isFinite(tabBarContentWidth)
+ ? tabBarContentWidth / tabCount
+ : undefined;
+ const { t } = useTranslation();
+ const homeLabel = t('tabs.home');
+ const kiloclawLabel =
+ fontScale > TAB_LABEL_WRAP_FONT_SCALE ? t('tabs.kiloclawWrapped') : t('common.kiloclaw');
+ const agentsLabel = t('common.agents');
+ const chatLabel = t('common.chat');
+ const profileLabel = t('common.profile');
+ // The label set in render order, so the visible/dropped decision measures
+ // exactly the strings each `TabBarLabel` renders.
+ const tabLabels = [
+ homeLabel,
+ ...(showKiloClawTab ? [kiloclawLabel] : []),
+ agentsLabel,
+ ...(showQuickChatTab ? [chatLabel] : []),
+ profileLabel,
+ ];
+ const showTabLabel = shouldShowTabLabel(fontScale, tabItemWidth, tabLabels);
const tabBarHeight = getEffectiveTabBarHeight({
bottomInset: bottom,
platform: Platform.OS,
fontScale,
+ showLabel: showTabLabel,
});
const tabBarHorizontalInset = getTabBarHorizontalInset({ left, right });
const tabIconSize = getTabBarIconSize(fontScale);
- const showKiloClawTab = useKiloClawTabVisible();
- const showQuickChatTab = useFeatureFlag(FEATURE_FLAG_QUICK_CHAT, false);
- const tabFlags = { showKiloClaw: showKiloClawTab, showQuickChat: showQuickChatTab };
- const tabCount = visibleTabCount(showKiloClawTab, showQuickChatTab);
- const { t } = useTranslation();
const { organizationId, isLoaded: orgLoaded } = useOrganization();
const { activeSessions, isLoading, isError } = useLiveAgentSessions({
organizationId,
@@ -126,7 +152,11 @@ export default function TabsLayout() {
// overlay; reserving it here shrank the band below the empty state's height
// in a short landscape window and parked its second line and action behind
// the bar (landscape spot defect e8).
- return (
+ //
+ // The label decision is published to the tab screens, whose content
+ // clearance must match the height this layout renders (the width rule can
+ // drop the labels without the callers seeing the window width).
+ const tabsLayout = (
,
+ tabBarLabel: ({ focused }) => ,
tabBarIcon: ({ color, focused }) => (
),
@@ -184,16 +214,10 @@ export default function TabsLayout() {
tabBarPosition('kiloclaw', tabFlags) ?? 2,
tabCount
),
- tabBarLabel: ({ focused }) => (
- TAB_LABEL_WRAP_FONT_SCALE
- ? t('tabs.kiloclawWrapped')
- : t('common.kiloclaw')
- }
- focused={focused}
- />
- ),
+ // The pre-wrapped copy is chosen once, from the same font scale the
+ // width decision measures, so `tabLabels` and the rendered label
+ // cannot disagree about which string is on the bar.
+ tabBarLabel: ({ focused }) => ,
tabBarIcon: ({ color, focused }) => (
),
@@ -218,9 +242,7 @@ export default function TabsLayout() {
tabBarPosition('agents', tabFlags) ?? 2,
tabCount
),
- tabBarLabel: ({ focused }) => (
-
- ),
+ tabBarLabel: ({ focused }) => ,
tabBarIcon: ({ color, focused }) => (
),
@@ -237,9 +259,7 @@ export default function TabsLayout() {
tabBarPosition('chat', tabFlags) ?? 3,
tabCount
),
- tabBarLabel: ({ focused }) => (
-
- ),
+ tabBarLabel: ({ focused }) => ,
tabBarIcon: ({ color, focused }) => (
),
@@ -255,9 +275,7 @@ export default function TabsLayout() {
tabCount,
tabCount
),
- tabBarLabel: ({ focused }) => (
-
- ),
+ tabBarLabel: ({ focused }) => ,
tabBarIcon: ({ color, focused }) => (
),
@@ -273,4 +291,5 @@ export default function TabsLayout() {
);
+ return {tabsLayout};
}
diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx
index 3a9020e880..e70daa9002 100644
--- a/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx
+++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx
@@ -102,6 +102,9 @@ vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
I18nManager: { isRTL: false },
Platform: { OS: 'android' },
+ // The header reads the window to decide whether its actions share the title
+ // row; this phone is wide enough for them to.
+ useWindowDimensions: () => ({ width: 390, fontScale: 1, height: 844 }),
}));
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0 }),
diff --git a/apps/mobile/src/components/account-settings-screen.arabic.mounted.test.tsx b/apps/mobile/src/components/account-settings-screen.arabic.mounted.test.tsx
index 3738e8c9cf..5e5c41364a 100644
--- a/apps/mobile/src/components/account-settings-screen.arabic.mounted.test.tsx
+++ b/apps/mobile/src/components/account-settings-screen.arabic.mounted.test.tsx
@@ -89,7 +89,9 @@ describe('AccountSettingsScreen in Arabic', () => {
expect(renderer.root.findByType('ScreenHeader').props.title).toBe('الحساب');
expect(findConfigureRow(renderer, 'اللغة').props).toMatchObject({ subtitle: 'العربية' });
expect(findConfigureRow(renderer, 'المضيفون الموثوقون')).toBeDefined();
- expect(findConfigureRow(renderer, 'مفاتيح المرور')).toBeDefined();
+ expect(findConfigureRow(renderer, 'مفاتيح المرور').props).toMatchObject({
+ subtitle: 'سجّل الدخول بدون كلمة مرور',
+ });
expect(findConfigureRow(renderer, 'جلسات الأجهزة')).toBeDefined();
});
diff --git a/apps/mobile/src/components/agents/new-session-configure-form.test.ts b/apps/mobile/src/components/agents/new-session-configure-form.test.ts
index e1781115de..9df8436635 100644
--- a/apps/mobile/src/components/agents/new-session-configure-form.test.ts
+++ b/apps/mobile/src/components/agents/new-session-configure-form.test.ts
@@ -133,12 +133,13 @@ vi.mock('@/components/ui/icons', () => ({ RefreshCw: 'RefreshCw' }));
vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' }));
// The profile row and the environment row both render a loading `Skeleton`,
-// whose module imports `react-native-reanimated`: this pure suite does not set
-// Reanimated up, and this project runs in plain Node, where the
-// Reanimated/worklets native entry cannot resolve (the published worklets
-// build uses bundler-style extensionless imports). The stub is the type the
-// pending-environment case asserts by name; its own rendering is not under test
-// here.
+// whose module imports `react-native-reanimated`: Reanimated's worklets entry
+// is unavailable in the pure project, so the skeleton leaf that reaches it is
+// stubbed as the other pure suites do. This suite does not set Reanimated up,
+// and this project runs in plain Node, where the Reanimated/worklets native
+// entry cannot resolve (the published worklets build uses bundler-style
+// extensionless imports). The stub is the type the pending-environment case
+// asserts by name; its own rendering is not under test here.
vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' }));
vi.mock('@/components/ui/segmented-control', () => ({
diff --git a/apps/mobile/src/components/agents/session-detail-content.test.ts b/apps/mobile/src/components/agents/session-detail-content.test.ts
index 7143c3ebb5..3d01dcb607 100644
--- a/apps/mobile/src/components/agents/session-detail-content.test.ts
+++ b/apps/mobile/src/components/agents/session-detail-content.test.ts
@@ -119,6 +119,9 @@ vi.mock('react-native', () => ({
KeyboardAvoidingView: 'KeyboardAvoidingView',
I18nManager: { isRTL: false },
Platform: { OS: 'ios' },
+ // The header reads the window to decide whether its actions share the title
+ // row; this phone is wide enough for them to.
+ useWindowDimensions: () => ({ width: 390, fontScale: 1, height: 844 }),
}));
vi.mock('react-native-reanimated', () => ({
default: { View: 'AnimatedView' },
diff --git a/apps/mobile/src/components/agents/session-list-content.tsx b/apps/mobile/src/components/agents/session-list-content.tsx
index e03768173c..c8fb28ef66 100644
--- a/apps/mobile/src/components/agents/session-list-content.tsx
+++ b/apps/mobile/src/components/agents/session-list-content.tsx
@@ -2,7 +2,7 @@
import { FlashList, type FlashListRef, type ListRenderItemInfo } from '@shopify/flash-list';
import { useFocusEffect, useScrollToTop } from 'expo-router';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { Platform, useWindowDimensions, View } from 'react-native';
+import { View } from 'react-native';
import { RefreshControl } from '@/components/ui/refresh-control';
import { ActivityIndicator } from '@/components/ui/activity-indicator';
import Animated, { FadeIn } from 'react-native-reanimated';
@@ -34,7 +34,7 @@ import { SESSION_LIST_SORT } from '@/lib/agent-session-sort';
import { useSessionMutations } from '@/lib/hooks/use-session-mutations';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { getRevisionSnapshot } from '@/lib/session-attention';
-import { getEffectiveTabBarHeight } from '@/lib/tab-bar-layout';
+import { useEffectiveTabBarHeight } from '@/lib/tab-bar-clearance';
export const FAB_SIZE = 56;
export const FAB_MARGIN = 16;
@@ -114,8 +114,10 @@ export function AgentSessionListContent({
const colors = useThemeColors();
const { t } = useTranslation();
- const { bottom, left, right } = useSafeAreaInsets();
- const { fontScale } = useWindowDimensions();
+ const { left, right } = useSafeAreaInsets();
+ // The tabs layout's width-aware label decision rides along, so the list
+ // clearance tracks the bar height the layout actually renders.
+ const tabBarHeight = useEffectiveTabBarHeight();
const { deleteSession, renameSession } = useSessionMutations();
// The stored refetch resolves void: a pull failure surfaces through the
// query error state (showInlineError below), so a settlement is always
@@ -145,16 +147,7 @@ export function AgentSessionListContent({
// must clear it or the last rows are stuck underneath it. The history list
// owns no FAB, so a bottom-only TabBar clearance is the only inset the
// content container needs.
- const tabBarOnlyClearanceStyle = useMemo(
- () => ({
- paddingBottom: getEffectiveTabBarHeight({
- bottomInset: bottom,
- platform: Platform.OS,
- fontScale,
- }),
- }),
- [bottom, fontScale]
- );
+ const tabBarOnlyClearanceStyle = useMemo(() => ({ paddingBottom: tabBarHeight }), [tabBarHeight]);
// The landscape side insets keep row text clear of the sensor housing
// (portrait insets are 0, keeping the geometry unchanged). They live on a
diff --git a/apps/mobile/src/components/agents/session-list-screen.tsx b/apps/mobile/src/components/agents/session-list-screen.tsx
index 3024e2b6da..1bb3b73363 100644
--- a/apps/mobile/src/components/agents/session-list-screen.tsx
+++ b/apps/mobile/src/components/agents/session-list-screen.tsx
@@ -1,14 +1,6 @@
/* eslint-disable max-lines -- The live list keeps its query, pull-refresh, keyboard container, and FAB orchestration together on one screen. */
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import {
- AppState,
- FlatList,
- KeyboardAvoidingView,
- Platform,
- Pressable,
- useWindowDimensions,
- View,
-} from 'react-native';
+import { AppState, FlatList, KeyboardAvoidingView, Platform, Pressable, View } from 'react-native';
import { RefreshControl } from '@/components/ui/refresh-control';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
@@ -37,7 +29,7 @@ import { ScreenHeader } from '@/components/screen-header';
import { AppAwareKeyboardPaddingView } from '@/components/kilo-chat/app-aware-keyboard-padding';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { getRevisionSnapshot } from '@/lib/session-attention';
-import { getEffectiveTabBarHeight } from '@/lib/tab-bar-layout';
+import { useEffectiveTabBarHeight } from '@/lib/tab-bar-clearance';
import { type ActiveSession, useLiveAgentSessions } from '@/lib/hooks/use-agent-sessions';
import { type Href, useFocusEffect, useNavigation, useRouter, useScrollToTop } from 'expo-router';
@@ -49,13 +41,10 @@ export function AgentSessionListScreen() {
const navigation = useNavigation();
const colors = useThemeColors();
const { t } = useTranslation();
- const { bottom, left, right } = useSafeAreaInsets();
- const { fontScale } = useWindowDimensions();
-
- const tabBarHeight = useMemo(
- () => getEffectiveTabBarHeight({ bottomInset: bottom, platform: Platform.OS, fontScale }),
- [bottom, fontScale]
- );
+ const { left, right } = useSafeAreaInsets();
+ // The tabs layout's width-aware label decision rides along, so this screen's
+ // clearance (list frame, FAB and state-surface insets) tracks the bar height.
+ const tabBarHeight = useEffectiveTabBarHeight();
// Android runs edge-to-edge and never resizes the window for the IME, so the
// native KeyboardAvoidingView is inert there; the app-aware container follows
// the keyboard events instead (the repo's one platform fork for this).
diff --git a/apps/mobile/src/components/agents/session-list-search-header.mounted.test.tsx b/apps/mobile/src/components/agents/session-list-search-header.mounted.test.tsx
index 47118c715f..a97e3604f0 100644
--- a/apps/mobile/src/components/agents/session-list-search-header.mounted.test.tsx
+++ b/apps/mobile/src/components/agents/session-list-search-header.mounted.test.tsx
@@ -110,4 +110,11 @@ describe('SessionListSearchHeader landscape sensor insets', () => {
expect(classes).toContain('min-h-');
expect(classes).not.toMatch(/(?:^|\s)py-/);
});
+
+ it('keeps the placeholder on one line at any width', async () => {
+ // A narrow window with a large font scale made the placeholder wrap inside
+ // the field and the field grow with it (e1-list-bottom.png).
+ const renderer = await mount();
+ expect(searchInput(renderer).props.numberOfLines).toBe(1);
+ });
});
diff --git a/apps/mobile/src/components/agents/session-list-search-header.tsx b/apps/mobile/src/components/agents/session-list-search-header.tsx
index a02b68b471..8e207abd03 100644
--- a/apps/mobile/src/components/agents/session-list-search-header.tsx
+++ b/apps/mobile/src/components/agents/session-list-search-header.tsx
@@ -68,6 +68,9 @@ export function SessionListSearchHeader({
// Height comes from `min-h`, never `py`: iOS insets the already-centered
// text rect by the padding and draws the placeholder low.
className="min-h-[26px] flex-1 text-[15px] leading-[normal] text-foreground"
+ // One line, always: at a narrow width with a large font scale the
+ // placeholder wrapped inside the field and the field grew with it.
+ numberOfLines={1}
placeholder={t('agents.search.searchSessionsPlaceholder')}
placeholderTextColor={colors.mutedForeground}
onChangeText={onChangeText}
diff --git a/apps/mobile/src/components/code-reviewer/manual-review-screen.mounted.test.tsx b/apps/mobile/src/components/code-reviewer/manual-review-screen.mounted.test.tsx
index b009b0294d..017063ef4f 100644
--- a/apps/mobile/src/components/code-reviewer/manual-review-screen.mounted.test.tsx
+++ b/apps/mobile/src/components/code-reviewer/manual-review-screen.mounted.test.tsx
@@ -39,6 +39,13 @@ vi.mock('react-native', () => ({
TextInput: 'TextInput',
View: 'View',
}));
+// The keyboard-padding leaf the screen renders reads the native side insets
+// through `react-native-safe-area-context`, whose module resolves to its
+// untransformed `react-native` entry (`src/index.tsx`) and breaks the mounted
+// project; every mounted suite mocks it.
+vi.mock('react-native-safe-area-context', () => ({
+ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
+}));
// The screen wraps its form in the shared keyboard-lift view, whose real module
// reads the platform and the safe-area insets (a react-native entry this node
// project cannot load). Sibling mounted specs stub the view for the same
diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx
index e2fbe3d2ff..d80088a20c 100644
--- a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx
+++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx
@@ -5,13 +5,12 @@ import { getCalendars } from 'expo-localization';
import { type Href, useRouter } from 'expo-router';
import { Plus, Settings2 } from '@/components/ui/icons';
import { useCallback, useMemo } from 'react';
-import { Platform, Pressable, useWindowDimensions, View, type ViewStyle } from 'react-native';
+import { Pressable, View, type ViewStyle } from 'react-native';
import { RefreshControl } from '@/components/ui/refresh-control';
import { RefreshProgress } from '@/components/ui/refresh-progress';
import { ActivityIndicator } from '@/components/ui/activity-indicator';
import Animated, { FadeIn } from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { QueryError } from '@/components/query-error';
import { captureEvent, CONVERSATION_CREATED_EVENT } from '@/lib/analytics/posthog';
@@ -21,7 +20,7 @@ import { Text } from '@/components/ui/text';
import { useManualRefresh } from '@/lib/hooks/use-manual-refresh';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { chatConversationPath } from '@/lib/kilo-chat-routes';
-import { getEffectiveTabBarHeight } from '@/lib/tab-bar-layout';
+import { useEffectiveTabBarHeight } from '@/lib/tab-bar-clearance';
import { EmptyConversationList } from './empty-conversation-list';
import { groupConversationsByActivity } from './conversation-list-groups';
@@ -101,8 +100,9 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) {
const { t } = useTranslation();
const router = useRouter();
const colors = useThemeColors();
- const { bottom } = useSafeAreaInsets();
- const { fontScale } = useWindowDimensions();
+ // The tabs layout's width-aware label decision rides along, so the list and
+ // FAB clearance track the bar height the layout actually renders.
+ const tabBarHeight = useEffectiveTabBarHeight();
const client = useKiloChatClient();
const eventClient = useEventServiceClient();
const activeAndFocused = useAppActiveAndFocused();
@@ -120,11 +120,6 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) {
const isFetchingNextPage = listQuery.isFetchingNextPage;
const fetchNextPage = listQuery.fetchNextPage;
const refetchConversations = listQuery.refetch;
- const tabBarHeight = getEffectiveTabBarHeight({
- bottomInset: bottom,
- platform: Platform.OS,
- fontScale,
- });
const listContentContainerStyle = useMemo(
() =>
({
diff --git a/apps/mobile/src/components/profile-screen.signout.mounted.test.tsx b/apps/mobile/src/components/profile-screen.signout.mounted.test.tsx
index cdf9e87c9e..a4d90c4d68 100644
--- a/apps/mobile/src/components/profile-screen.signout.mounted.test.tsx
+++ b/apps/mobile/src/components/profile-screen.signout.mounted.test.tsx
@@ -35,6 +35,14 @@ vi.mock('react-native-reanimated', () => ({
LinearTransition: {},
}));
+// ProfileScreen reads the landscape side insets, so the real native module
+// would load here. Its build requires `react-native` itself, whose Flow source
+// the vitest transform cannot parse (see `test/render-with-providers.tsx`), so
+// the harness mocks it exactly as `profile-screen.queries.mounted.test.tsx` does.
+// ProfileScreen reads the landscape side insets, so the real native module
+// would load here. Its build requires `react-native` itself, whose Flow source
+// the vitest transform cannot parse (see `test/render-with-providers.tsx`), so
+// the harness mocks it exactly as `profile-screen.queries.mounted.test.tsx` does.
// The screen reads its side insets through `@/lib/screen-insets`, whose real
// `react-native-safe-area-context` entry is a Flow source this pipeline cannot
// transform; the insets are irrelevant to the sign-out flow.
diff --git a/apps/mobile/src/components/screen-header.mounted.test.tsx b/apps/mobile/src/components/screen-header.mounted.test.tsx
index 611fd7f7cd..9780ee5da0 100644
--- a/apps/mobile/src/components/screen-header.mounted.test.tsx
+++ b/apps/mobile/src/components/screen-header.mounted.test.tsx
@@ -10,7 +10,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { OFFLINE_BANNER_HEIGHT } from '@/lib/offline-banner-state';
import { SESSION_HEADER_TITLE_LINES } from '@/components/agents/session-header';
-import { ScreenHeader } from './screen-header';
+import { ScreenHeader, shouldStackHeaderActions } from './screen-header';
import { OfflineBannerSpaceProvider } from './offline-banner-space';
const routerState = vi.hoisted(() => ({
@@ -22,6 +22,8 @@ const routerState = vi.hoisted(() => ({
const i18nManager = vi.hoisted(() => ({ isRTL: false }));
const safeArea = vi.hoisted(() => ({ top: 0, bottom: 0, left: 0, right: 0 }));
const platform = vi.hoisted(() => ({ OS: 'ios' as 'ios' | 'android' }));
+/** A normal phone window: wide enough for the title and its actions to share a row. */
+const layout = vi.hoisted(() => ({ width: 390, fontScale: 1 }));
vi.mock('expo-router', () => ({
useRouter: () => routerState,
@@ -31,6 +33,7 @@ vi.mock('react-native', () => ({
Platform: platform,
Pressable: 'Pressable',
View: 'View',
+ useWindowDimensions: () => ({ width: layout.width, height: 800, fontScale: layout.fontScale }),
}));
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => safeArea,
@@ -75,6 +78,21 @@ function findTitlePressable(root: TestInstance): TestInstance {
);
}
+/**
+ * The row the header puts `headerRight` on when the window is too narrow for
+ * the title and the actions to share one. Only that variant carries `mt-2`
+ * without the single-row wrapper's `max-w-[50%]`.
+ */
+function isStackedActionsRow(node: TestInstance): boolean {
+ const className = String(node.props.className ?? '');
+ return (
+ typeof node.type === 'string' &&
+ (node.type as string) === 'View' &&
+ className.includes('mt-2') &&
+ !className.includes('max-w-[50%]')
+ );
+}
+
function findIcon(back: TestInstance, type: string): TestInstance {
const icons = back.findAll(node => typeof node.type === 'string' && node.type === type);
const icon = icons[0];
@@ -197,6 +215,7 @@ describe('ScreenHeader mounted', () => {
i18nManager.isRTL = false;
platform.OS = 'ios';
Object.assign(safeArea, { top: 0, bottom: 0, left: 0, right: 0 });
+ Object.assign(layout, { width: 390, fontScale: 1 });
});
it('gives the back control a 44-point target and no hit slop', () => {
@@ -702,3 +721,97 @@ describe('ScreenHeader mounted', () => {
expect(eyebrow.children).toEqual(['\u00A0']);
});
});
+
+describe('header actions row', () => {
+ it('keeps the actions on the title row at a normal phone width', () => {
+ const renderer = renderHeader({ title: 'Agents', size: 'large', headerRight: 'RIGHT' });
+
+ expect(renderer.root.findAll(isStackedActionsRow)).toHaveLength(0);
+ const actions = renderer.root.find(
+ node =>
+ typeof node.type === 'string' &&
+ (node.type as string) === 'View' &&
+ String(node.props.className ?? '').includes('max-w-[50%]')
+ );
+ // The inline wrapper's gap is the logical start margin (`ms-3`), which the
+ // RTL swap cannot mirror away; the title's box keeps its `shrink` cap.
+ expect(actions.props.className).toContain('ms-3');
+ });
+
+ it('drops the actions to their own row when the window cannot hold the title beside them', () => {
+ // The e1 geometry: 160dp wide with a 1.5 font scale left the 30px title
+ // 48dp, which broke it and the mono link beside it into letter columns.
+ layout.width = 160;
+ layout.fontScale = 1.5;
+ const renderer = renderHeader({ title: 'Agents', size: 'large', headerRight: 'RIGHT' });
+
+ expect(renderer.root.findAll(isStackedActionsRow)).toHaveLength(1);
+ expect(
+ renderer.root.findAll(
+ node =>
+ typeof node.type === 'string' &&
+ (node.type as string) === 'View' &&
+ String(node.props.className ?? '').includes('max-w-[50%]')
+ )
+ ).toHaveLength(0);
+ // The title still shares its row with the back control; only the actions moved.
+ const title = renderer.root.findByProps({ accessibilityRole: 'header' });
+ expect(title.parent?.parent).toBe(findBackPressable(renderer.root).parent);
+ });
+
+ it('reflows a centered modal header the same way', () => {
+ layout.width = 160;
+ layout.fontScale = 1.5;
+ const renderer = renderHeader({ title: 'Agents', modal: true, headerRight: 'RIGHT' });
+
+ expect(renderer.root.findAll(isStackedActionsRow)).toHaveLength(1);
+ const title = renderer.root.findByProps({ accessibilityRole: 'header' });
+ expect(title.parent?.parent?.parent).toBe(findBackPressable(renderer.root).parent);
+ });
+
+ it('leaves a header without actions on its single row', () => {
+ layout.width = 160;
+ layout.fontScale = 1.5;
+ const renderer = renderHeader({ title: 'Agents', size: 'large' });
+
+ expect(renderer.root.findAll(isStackedActionsRow)).toHaveLength(0);
+ });
+});
+
+describe('header status line', () => {
+ it('keeps the eyebrow on one line at the reported narrow geometry', () => {
+ // e1: a 160dp window at a 1.5 font scale broke '1 LIVE' into a letter
+ // column beside the title and grew the header down over the search field.
+ layout.width = 160;
+ layout.fontScale = 1.5;
+ const renderer = renderHeader({ title: 'Agents', size: 'large', eyebrow: '1 LIVE' });
+
+ const eyebrow = renderer.root.findByType('Eyebrow');
+ expect(eyebrow.props.numberOfLines).toBe(1);
+ expect(eyebrow.children).toEqual(['1 LIVE']);
+ });
+});
+
+describe('shouldStackHeaderActions', () => {
+ it('reflows at the reported 160dp window with a 1.5 font scale', () => {
+ expect(shouldStackHeaderActions(160, 1.5)).toBe(true);
+ });
+
+ it('keeps one row at every phone width the scenarios cover', () => {
+ expect(shouldStackHeaderActions(320, 1)).toBe(false);
+ expect(shouldStackHeaderActions(360, 1)).toBe(false);
+ expect(shouldStackHeaderActions(360, 2)).toBe(false);
+ expect(shouldStackHeaderActions(390, 2)).toBe(false);
+ });
+
+ it('reflows a window too narrow for the title beside the actions', () => {
+ expect(shouldStackHeaderActions(160, 1)).toBe(true);
+ expect(shouldStackHeaderActions(220, 1.5)).toBe(true);
+ expect(shouldStackHeaderActions(320, 2.5)).toBe(true);
+ });
+
+ it('treats a missing window measurement as a single row', () => {
+ expect(shouldStackHeaderActions(Number.NaN, 1)).toBe(false);
+ expect(shouldStackHeaderActions(160, Number.NaN)).toBe(true);
+ });
+});
diff --git a/apps/mobile/src/components/screen-header.tsx b/apps/mobile/src/components/screen-header.tsx
index 8fee5bd2f1..0222fe4a17 100644
--- a/apps/mobile/src/components/screen-header.tsx
+++ b/apps/mobile/src/components/screen-header.tsx
@@ -1,7 +1,7 @@
import { type Href, useRouter } from 'expo-router';
import { ChevronDown } from '@/components/ui/icons';
import { DirectionalChevronLeft } from '@/components/ui/directional-icons';
-import { I18nManager, Platform, Pressable, View } from 'react-native';
+import { I18nManager, Platform, Pressable, useWindowDimensions, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -22,6 +22,41 @@ import { cn } from '@/lib/utils';
*/
const MODAL_HEADER_TOP_PADDING = 32;
+/**
+ * Content width (dp, at font scale 1) a header needs for its title and its
+ * actions to share one row.
+ *
+ * A narrow window cannot hold both. At the e1 geometry — 160dp wide with a 1.5
+ * font scale — the shared row left the heading 48dp: the 30px list title, its
+ * eyebrow, and the mono link beside it need about 180dp together, which is
+ * about 120dp at scale 1. Every one of them broke into a single-letter column
+ * and grew the header down over the search field. Below this width the actions
+ * take their own row under the title, the reflow the design system asks of
+ * narrow product UI. Wider windows keep the single-row layout byte-identical.
+ */
+const HEADER_SINGLE_ROW_MIN_CONTENT_WIDTH = 120;
+
+/**
+ * The horizontal gutter a header's content sits inside. The list screens use
+ * the widest one in service (`px-[22px]`, 44dp); the default `px-4` is
+ * narrower, so the check reflows a hair early for those callers instead of
+ * squeezing them.
+ */
+const HEADER_CONTENT_GUTTER = 44;
+
+/**
+ * Whether the header's actions drop to their own row instead of sharing the
+ * title's. `width` is the window width in dp; `fontScale` is the system font
+ * scale, so a large font reflows before a small one at the same width.
+ */
+export function shouldStackHeaderActions(width: number, fontScale: number): boolean {
+ if (!Number.isFinite(width)) {
+ return false;
+ }
+ const scale = Number.isFinite(fontScale) && fontScale > 0 ? fontScale : 1;
+ return width - HEADER_CONTENT_GUTTER < HEADER_SINGLE_ROW_MIN_CONTENT_WIDTH * scale;
+}
+
/**
* Ceiling for the header title's line cap. The reserved box grows by a full
* line height per cap, so three lines is the most a header should take; the
@@ -121,8 +156,13 @@ export function ScreenHeader({
const router = useRouter();
const colors = useThemeColors();
const { t } = useTranslation();
+ const { width: windowWidth, fontScale } = useWindowDimensions();
const canGoBack = showBackButton ?? (router.canGoBack() || backFallback !== undefined);
const isOfflineBannerVisible = useOfflineBannerSpace();
+ // A window too narrow to hold the title beside its actions drops the actions
+ // to their own row instead of squeezing the title into a single-letter
+ // column. Only a header that has actions can reflow this way.
+ const stackActions = headerRight != null && shouldStackHeaderActions(windowWidth, fontScale);
// A modal is a native sheet that owns its own top inset; the header keeps the
// fixed grabber clearance on both platforms. A pinned header adds the app
@@ -230,6 +270,10 @@ export function ScreenHeader({
{eyebrow || reserveEyebrow ? (
) : null;
const centeredControls =
- separateHeading && backControl && !headerRight ? (
+ separateHeading && backControl ? (
) : null;
@@ -293,25 +337,39 @@ export function ScreenHeader({
{separateHeading ? (
-
- {backControl}
- {heading}
- {headerRight ? (
- {headerRight}
- ) : (
- centeredControls
- )}
-
- ) : (
-
-
+ <>
+
{backControl}
- {heading}
+ {heading}
+ {headerRight && !stackActions ? (
+ {headerRight}
+ ) : (
+ centeredControls
+ )}
+
+ {headerRight && stackActions ? (
+ // The actions keep the title's own row only while it can hold a
+ // readable title; below that they take a row of their own. They
+ // keep the row's full width so a long translation still wraps
+ // inside it instead of running off the edge.
+ {headerRight}
+ ) : null}
+ >
+ ) : (
+ <>
+
+
+ {backControl}
+ {heading}
+
+ {headerRight && !stackActions ? (
+ {headerRight}
+ ) : null}
- {headerRight ? (
- {headerRight}
+ {headerRight && stackActions ? (
+ {headerRight}
) : null}
-
+ >
)}
diff --git a/apps/mobile/src/components/security-agent/finding-detail-screen.mounted.test.tsx b/apps/mobile/src/components/security-agent/finding-detail-screen.mounted.test.tsx
index 5acd212747..8ebb33b7a4 100644
--- a/apps/mobile/src/components/security-agent/finding-detail-screen.mounted.test.tsx
+++ b/apps/mobile/src/components/security-agent/finding-detail-screen.mounted.test.tsx
@@ -52,6 +52,9 @@ vi.mock('react-native', () => ({
Pressable: 'Pressable',
I18nManager: { isRTL: false },
Platform: { OS: 'ios' },
+ // The header reads the window to decide whether its actions share the title
+ // row; this phone is wide enough for them to.
+ useWindowDimensions: () => ({ width: 390, fontScale: 1, height: 844 }),
}));
vi.mock('@/components/ui/icons', () => ({
Ban: 'Ban',
diff --git a/apps/mobile/src/components/tab-screen.mounted.test.tsx b/apps/mobile/src/components/tab-screen.mounted.test.tsx
index 9a79a978f2..9a7664dd34 100644
--- a/apps/mobile/src/components/tab-screen.mounted.test.tsx
+++ b/apps/mobile/src/components/tab-screen.mounted.test.tsx
@@ -1,30 +1,66 @@
import { createElement } from 'react';
-import { describe, expect, it, vi } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { TabBarLabelContext } from '@/lib/tab-bar-clearance';
+import { getTabBarIconForwardHeight, getTabBarOverlayHeight } from '@/lib/tab-bar-layout';
import { renderWithProviders } from '@/test/render-with-providers';
-import { TabScreenScrollView } from './tab-screen';
+
+import { useTabBarBottomPadding } from './tab-screen';
+
+/** Mirrors `TAB_SCREEN_BOTTOM_GAP` in `tab-screen.tsx`. */
+const TAB_SCREEN_BOTTOM_GAP = 16;
+const DEFAULT_FONT_SCALE = 1.5;
+const layout = vi.hoisted(() => ({ bottom: 16, fontScale: 1.5 }));
vi.mock('react-native', () => ({
- Platform: { OS: 'ios' },
+ Platform: { OS: 'android' },
ScrollView: 'ScrollView',
- View: 'View',
- useWindowDimensions: () => ({ fontScale: 1 }),
+ useWindowDimensions: () => ({ height: 320, width: 160, fontScale: layout.fontScale }),
}));
vi.mock('react-native-safe-area-context', () => ({
- useSafeAreaInsets: () => ({ bottom: 34 }),
+ useSafeAreaInsets: () => ({ bottom: layout.bottom, left: 0, right: 0, top: 0 }),
}));
-vi.mock('@/components/ui/refresh-progress', () => ({ RefreshProgress: 'RefreshProgress' }));
-describe('TabScreenScrollView', () => {
- it('reserves the tab bar and final gap outside the scroll viewport', async () => {
- const { renderer, unmount } = await renderWithProviders(
- createElement(TabScreenScrollView, null, createElement('Content'))
+function Probe() {
+ return createElement('Probe', { padding: useTabBarBottomPadding() });
+}
+
+/** The clearance `useTabBarBottomPadding` reserves under an optional label decision. */
+async function clearanceFor(showLabel: boolean | null): Promise {
+ const probe = createElement(Probe);
+ const ui =
+ showLabel === null ? probe : createElement(TabBarLabelContext, { value: showLabel }, probe);
+ const mounted = await renderWithProviders(ui);
+ try {
+ return mounted.renderer.root.findByType('Probe').props.padding as number;
+ } finally {
+ mounted.unmount();
+ }
+}
+
+afterEach(() => {
+ layout.fontScale = DEFAULT_FONT_SCALE;
+});
+
+describe('tab screen bottom clearance', () => {
+ it('follows the published label decision, not just the font scale', async () => {
+ // At 1.5 the font-scale rule alone keeps the labels (the default answer);
+ // the width rule dropped them, so the clearance must match the compact bar.
+ await expect(clearanceFor(false)).resolves.toBe(
+ getTabBarIconForwardHeight(layout.bottom, 'android') + TAB_SCREEN_BOTTOM_GAP
);
- const findByType = (type: string) =>
- renderer.root.findAll(node => typeof node.type === 'string' && node.type === type);
+ });
- expect(findByType('ScrollView')[0]?.props.style).toEqual([undefined, { marginBottom: 100 }]);
- expect(findByType('View')).toHaveLength(0);
- unmount();
+ it('keeps the label-inclusive height when the layout keeps the labels', async () => {
+ await expect(clearanceFor(true)).resolves.toBe(
+ getTabBarOverlayHeight(layout.bottom, 'android', layout.fontScale) + TAB_SCREEN_BOTTOM_GAP
+ );
+ });
+
+ it('falls back to the font-scale rule outside the tabs navigator', async () => {
+ layout.fontScale = 2.5;
+ await expect(clearanceFor(null)).resolves.toBe(
+ getTabBarIconForwardHeight(layout.bottom, 'android') + TAB_SCREEN_BOTTOM_GAP
+ );
});
});
diff --git a/apps/mobile/src/components/tab-screen.tsx b/apps/mobile/src/components/tab-screen.tsx
index eb4cd291f5..d807c19671 100644
--- a/apps/mobile/src/components/tab-screen.tsx
+++ b/apps/mobile/src/components/tab-screen.tsx
@@ -1,19 +1,13 @@
import { type Ref } from 'react';
-import { Platform, ScrollView, type ScrollViewProps, useWindowDimensions } from 'react-native';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { ScrollView, type ScrollViewProps } from 'react-native';
-import { getEffectiveTabBarHeight } from '@/lib/tab-bar-layout';
+import { useEffectiveTabBarHeight } from '@/lib/tab-bar-clearance';
const TAB_SCREEN_BOTTOM_GAP = 16;
// FlatList/FlashList screens use this directly for contentContainerStyle.paddingBottom.
export function useTabBarBottomPadding() {
- const { bottom } = useSafeAreaInsets();
- const { fontScale } = useWindowDimensions();
- return (
- getEffectiveTabBarHeight({ bottomInset: bottom, platform: Platform.OS, fontScale }) +
- TAB_SCREEN_BOTTOM_GAP
- );
+ return useEffectiveTabBarHeight() + TAB_SCREEN_BOTTOM_GAP;
}
export function TabScreenScrollView({
diff --git a/apps/mobile/src/lib/tab-bar-clearance.ts b/apps/mobile/src/lib/tab-bar-clearance.ts
new file mode 100644
index 0000000000..366b83eda3
--- /dev/null
+++ b/apps/mobile/src/lib/tab-bar-clearance.ts
@@ -0,0 +1,33 @@
+import { createContext, useContext } from 'react';
+import { Platform, useWindowDimensions } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { getEffectiveTabBarHeight } from '@/lib/tab-bar-layout';
+
+/**
+ * The tab layout's label decision for the current window, published to the tab
+ * screens so their content clearance matches the rendered bar height. The
+ * decision turns on the window width, which a clearance caller cannot see from
+ * `fontScale` alone: without it a caller reserves the label-inclusive height
+ * while the bar renders compact (labels dropped for width). `null` outside the
+ * tabs navigator, where `getEffectiveTabBarHeight` keeps its font-scale-only
+ * default.
+ */
+export const TabBarLabelContext = createContext(null);
+
+/**
+ * Effective rendered tab bar height for a screen's content clearance: the tabs
+ * layout's label decision (`TabBarLabelContext`), the safe-area bottom inset and
+ * the platform.
+ */
+export function useEffectiveTabBarHeight(): number {
+ const { bottom } = useSafeAreaInsets();
+ const { fontScale } = useWindowDimensions();
+ const showLabel = useContext(TabBarLabelContext) ?? undefined;
+ return getEffectiveTabBarHeight({
+ bottomInset: bottom,
+ platform: Platform.OS,
+ fontScale,
+ showLabel,
+ });
+}
diff --git a/apps/mobile/src/lib/tab-bar-layout.test.ts b/apps/mobile/src/lib/tab-bar-layout.test.ts
index 43e1f6b8f1..9a58ea1825 100644
--- a/apps/mobile/src/lib/tab-bar-layout.test.ts
+++ b/apps/mobile/src/lib/tab-bar-layout.test.ts
@@ -1,3 +1,8 @@
+// eslint-disable-next-line import/no-nodejs-modules -- vitest-only parity check, runs in node, never bundled into the app
+import { readFileSync } from 'node:fs';
+// eslint-disable-next-line import/no-nodejs-modules -- vitest-only parity check, runs in node, never bundled into the app
+import { fileURLToPath } from 'node:url';
+
import { describe, expect, it } from 'vitest';
import { i18n } from '@/i18n';
@@ -13,10 +18,21 @@ import {
TAB_LABEL_WRAP_FONT_SCALE,
tabAccessibilityLabel,
tabBarPosition,
+ tabLabelFits,
tabLabelNumberOfLines,
+ tabLabelWidth,
visibleTabCount,
} from '@/lib/tab-bar-layout';
+const LAYOUT_SOURCE = readFileSync(
+ fileURLToPath(new URL('tab-bar-layout.ts', import.meta.url)),
+ 'utf8'
+);
+const LABEL_SOURCE = readFileSync(
+ fileURLToPath(new URL('../components/tab-bar-label.tsx', import.meta.url)),
+ 'utf8'
+);
+
describe('getTabBarOverlayHeight', () => {
it('includes the bottom safe area on iOS', () => {
expect(getTabBarOverlayHeight(34, 'ios')).toBe(84);
@@ -83,6 +99,17 @@ describe('getEffectiveTabBarHeight', () => {
70
);
});
+
+ it('honours a caller-supplied label decision at the default font scale', () => {
+ expect(
+ getEffectiveTabBarHeight({
+ bottomInset: 16,
+ platform: 'android',
+ fontScale: 1,
+ showLabel: false,
+ })
+ ).toBe(getTabBarIconForwardHeight(16, 'android'));
+ });
});
describe('getTabBarIconForwardHeight', () => {
it('collapses to the base height when labels are hidden at large font scale', () => {
@@ -138,6 +165,51 @@ describe('getTabBarHorizontalInset', () => {
});
});
+describe('tabLabelWidth', () => {
+ it('measures a single unbroken word at the tab label size', () => {
+ // "Profile" is 7 glyphs at (0.6em x 11) + 0.2 tracking = 47.6
+ expect(tabLabelWidth('Profile')).toBeCloseTo(47.6, 5);
+ });
+
+ it('measures a wrapped label line by line, but a space-separated label as one line', () => {
+ // Only the explicit break splits the label into two lines; a plain space
+ // stays on the single line the renderer lays out.
+ expect(tabLabelWidth('Kilo\nClaw')).toBeCloseTo(tabLabelWidth('Kilo'), 5);
+ expect(tabLabelWidth('Bogga shakhsiga')).toBeCloseTo(15 * (0.6 * 11 + 0.2), 5);
+ });
+
+ it('counts a plain space as part of the single rendered line', () => {
+ // "Kilo Claw" is one line of 9 glyphs, not the 4-glyph widest word.
+ expect(tabLabelWidth('Kilo Claw')).toBeCloseTo(9 * (0.6 * 11 + 0.2), 5);
+ });
+
+ it('scales the glyph advance (not the tracking) with the system font scale', () => {
+ expect(tabLabelWidth('Profile', 2)).toBeCloseTo(7 * (0.6 * 11 * 2 + 0.2), 5);
+ });
+
+ it('counts CJK/Kana/Hangul glyphs as one em wide', () => {
+ expect(tabLabelWidth('設定')).toBeCloseTo(2 * (11 + 0.2), 5);
+ });
+});
+
+describe('tabLabelFits', () => {
+ it('rejects a label wider than its tab minus the item padding', () => {
+ // 160dp / 3 tabs = 53.3dp box, less 10dp padding = 43.3dp for a 47.6dp word
+ expect(tabLabelFits('Profile', 160 / 3)).toBe(false);
+ });
+
+ it('accepts the same label on a normal phone width', () => {
+ expect(tabLabelFits('Profile', 360 / 3)).toBe(true);
+ });
+
+ it('rejects a space-separated label that overflows its single rendered line', () => {
+ // "KILO CLAW" renders on one 9-glyph line (61.2dp), so it cannot fit the
+ // 43.3dp label box even though either word alone would.
+ expect(tabLabelFits('Kilo Claw', 160 / 3)).toBe(false);
+ expect(tabLabelFits('Bogga shakhsiga', 160 / 3)).toBe(false);
+ });
+});
+
describe('shouldShowTabLabel', () => {
it('keeps the label below the icon-forward threshold', () => {
expect(shouldShowTabLabel(1)).toBe(true);
@@ -149,6 +221,37 @@ describe('shouldShowTabLabel', () => {
expect(shouldShowTabLabel(2.5)).toBe(false);
expect(shouldShowTabLabel(3)).toBe(false);
});
+
+ it('drops the labels when any label is too wide for its tab', () => {
+ // Reported geometry: 160dp / 5 tabs = 32dp per tab, 22dp for the label
+ expect(shouldShowTabLabel(1, 160 / 5, ['Home', 'KiloClaw', 'Agents', 'Chat', 'Profile'])).toBe(
+ false
+ );
+ });
+
+ it('keeps the labels when every label fits its tab', () => {
+ expect(shouldShowTabLabel(1, 360 / 5, ['Home', 'KiloClaw', 'Agents', 'Chat', 'Profile'])).toBe(
+ true
+ );
+ });
+
+ it('drops the labels when a space-separated label overflows its single line', () => {
+ // `so` `common.profile` = "Bogga shakhsiga" (15 glyphs = 102dp) does not
+ // fit a 43.3dp label box at 160dp, so the bar goes icon-only instead of
+ // rendering a tail-ellipsized label.
+ expect(shouldShowTabLabel(1, 160 / 3, ['Home', 'Agents', 'Bogga shakhsiga'])).toBe(false);
+ // "Quick Chat" (10 glyphs = 68dp) overflows even though each word alone
+ // fits, so the widest-word measure would wrongly keep the labels.
+ expect(shouldShowTabLabel(1, 160 / 3, ['Home', 'Agents', 'Quick Chat'])).toBe(false);
+ });
+
+ it('keeps a space-separated label once its whole line fits the tab', () => {
+ expect(shouldShowTabLabel(1, 360 / 3, ['Home', 'Agents', 'Bogga shakhsiga'])).toBe(true);
+ });
+
+ it('keeps the width rule from overriding the font-scale rule', () => {
+ expect(shouldShowTabLabel(2, 160 / 5, ['Home'])).toBe(false);
+ });
});
describe('tabLabelNumberOfLines', () => {
@@ -166,6 +269,27 @@ describe('tabLabelNumberOfLines', () => {
});
});
+describe('mirrored tab label metrics', () => {
+ // The metrics at the top of this module are copied from the label
+ // component's style, so the "keep these in step" pointer must name the file
+ // that actually owns that style, and the copied tokens must still match it.
+ it('points at the component that owns the tab label style', () => {
+ expect(LAYOUT_SOURCE).toContain('apps/mobile/src/components/tab-bar-label.tsx');
+ expect(LABEL_SOURCE).toContain('export function TabBarLabel');
+ });
+
+ it('mirrors the component font size and tracking in the width estimate', () => {
+ // JetBrains Mono advances 0.6em per glyph; the label adds 0.2px tracking.
+ // `tabLabelWidth` must use the same 11px/0.2px the component's class sets,
+ // and the component must render one line via `tabLabelNumberOfLines`.
+ expect(LABEL_SOURCE).toContain(
+ 'font-mono-medium text-[11px] leading-4 uppercase tracking-[0.2px]'
+ );
+ expect(LABEL_SOURCE).toContain('numberOfLines={tabLabelNumberOfLines(label)}');
+ expect(tabLabelWidth('A')).toBeCloseTo(0.6 * 11 + 0.2);
+ });
+});
+
describe('shouldHideTabBar', () => {
it('hides tabs for full-screen nested routes', () => {
expect(shouldHideTabBar('/chat/sandbox-1/instance-picker')).toBe(true);
diff --git a/apps/mobile/src/lib/tab-bar-layout.ts b/apps/mobile/src/lib/tab-bar-layout.ts
index c9972c4314..ecbc466b89 100644
--- a/apps/mobile/src/lib/tab-bar-layout.ts
+++ b/apps/mobile/src/lib/tab-bar-layout.ts
@@ -16,6 +16,21 @@ export const TAB_ICON_FORWARD_FONT_SCALE = 2;
const TAB_ICON_BASE_SIZE = 22;
const TAB_ICON_MAX_SIZE = 26;
+// Label metrics mirrored from `TabBarLabel`
+// (`apps/mobile/src/components/tab-bar-label.tsx`): `font-mono-medium text-[11px]
+// leading-4 uppercase tracking-[0.2px]`. Keep these in step with that style.
+const TAB_LABEL_FONT_SIZE = 11;
+const TAB_LABEL_LETTER_SPACING = 0.2;
+/** JetBrains Mono (the `font-mono-medium` label) advances 0.6em per glyph. */
+const MONO_ADVANCE_EM = 0.6;
+/** CJK/Kana/Hangul/fullwidth glyphs render one em wide in the same stack. */
+const FULL_WIDTH_ADVANCE_EM = 1;
+/**
+ * react-navigation's vertical tab item (`tabVerticalUiKit: { padding: 5 }` in
+ * `BottomTabItem`), so a tab's label box is `tabWidth - 10`.
+ */
+const TAB_ITEM_HORIZONTAL_PADDING = 10;
+
type TabBarPlatform = 'android' | 'ios' | 'macos' | 'windows' | 'web';
export function getTabBarOverlayHeight(
@@ -59,18 +74,24 @@ export function getTabBarIconSize(fontScale = 1): number {
* Effective rendered tab bar height for the current platform/font scale. This
* is the single source of truth for both the tab bar itself and the content
* clearance below it: it switches to the compact icon-forward height once labels
- * are hidden, and otherwise uses the label-inclusive overlay height.
+ * are hidden, and otherwise uses the label-inclusive overlay height. Callers
+ * that already decided the label state (from the window width) pass it as
+ * `showLabel`; the default keeps the font-scale-only answer. Tab screens use
+ * `useEffectiveTabBarHeight` (`tab-bar-clearance.ts`), which supplies the tab
+ * layout's decision so the clearance cannot drift from the rendered height.
*/
export function getEffectiveTabBarHeight({
bottomInset,
platform,
fontScale = 1,
+ showLabel = shouldShowTabLabel(fontScale),
}: {
bottomInset: number;
platform: TabBarPlatform;
fontScale?: number;
+ showLabel?: boolean;
}): number {
- return shouldShowTabLabel(fontScale)
+ return showLabel
? getTabBarOverlayHeight(bottomInset, platform, fontScale)
: getTabBarIconForwardHeight(bottomInset, platform);
}
@@ -94,8 +115,73 @@ export function getTabBarHorizontalInset({
};
}
-export function shouldShowTabLabel(fontScale = 1): boolean {
- return fontScale < TAB_ICON_FORWARD_FONT_SCALE;
+/**
+ * Whether the tab bar shows visible labels. Labels are dropped at and above
+ * `TAB_ICON_FORWARD_FONT_SCALE` (the bar would balloon with the scaled label),
+ * and when any label is too wide for its tab at the current window width (RN
+ * would tail-ellipsize it, or wrap a `\n` line mid-word). With no `labels` the
+ * per-tab width is unknown, so only the font-scale rule applies — the previous
+ * behaviour, unchanged for callers that do not pass a tab width.
+ */
+export function shouldShowTabLabel(
+ fontScale = 1,
+ tabWidth = Number.POSITIVE_INFINITY,
+ labels: readonly string[] = []
+): boolean {
+ if (fontScale >= TAB_ICON_FORWARD_FONT_SCALE) {
+ return false;
+ }
+ if (labels.length === 0) {
+ return true;
+ }
+ return labels.every(label => tabLabelFits(label, tabWidth, fontScale));
+}
+
+/**
+ * Characters the tab label's monospace stack draws one em wide: CJK
+ * ideographs, Kana, Hangul and fullwidth forms/punctuation. A conservative
+ * class — it only bounds the glyph advance, so a stray match over-estimates
+ * the width and hides the labels slightly earlier rather than leaving a
+ * mid-word break on screen. Ranges mirror the `is-fullwidth-code-point` class.
+ */
+const FULL_WIDTH_CHARACTER =
+ /[\u1100-\u115F\u2329\u232A\u2E80-\u3247\u3250-\u4DBF\u4E00-\uA4C6\uA960-\uA97C\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6B\uFF01-\uFF60\uFFE0-\uFFE6\u{1F200}-\u{1F251}\u{20000}-\u{3FFFD}]/u;
+
+function isFullWidthCharacter(character: string): boolean {
+ return FULL_WIDTH_CHARACTER.test(character);
+}
+
+/**
+ * Estimated rendered width (dp) of the widest line of a tab label. A label is
+ * one line (`tabLabelNumberOfLines`), except copy that carries its own break
+ * (`Kilo\nClaw`), whose wider line is the one that has to fit. Only an explicit
+ * break starts a new line: an ordinary space stays on the same one line and
+ * counts toward its width (the renderer tail-ellipsizes, it does not wrap on a
+ * space). The estimate is deliberately conservative: it only decides whether
+ * the labels are dropped, so over-estimating hides them slightly early and
+ * never leaves a clipped label on screen.
+ */
+export function tabLabelWidth(label: string, fontScale = 1): number {
+ let widest = 0;
+ for (const line of label.split('\n')) {
+ let lineWidth = 0;
+ for (const character of line) {
+ const advanceEm = isFullWidthCharacter(character) ? FULL_WIDTH_ADVANCE_EM : MONO_ADVANCE_EM;
+ lineWidth += advanceEm * TAB_LABEL_FONT_SIZE * fontScale + TAB_LABEL_LETTER_SPACING;
+ }
+ widest = Math.max(widest, lineWidth);
+ }
+ return widest;
+}
+
+/**
+ * Whether `label` fits a tab item `tabWidth` dp wide without wrapping mid-word.
+ * The item's own horizontal padding is removed first, matching
+ * react-navigation's tab item (`padding: 5` on each side).
+ */
+export function tabLabelFits(label: string, tabWidth: number, fontScale = 1): boolean {
+ const available = Math.max(tabWidth - TAB_ITEM_HORIZONTAL_PADDING, 0);
+ return tabLabelWidth(label, fontScale) <= available;
}
/**