diff --git a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx
index 119f6a8bc9..1cbe6eb571 100644
--- a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx
@@ -10,6 +10,7 @@ import { StateSurfaceInsets } from '@/components/centered-state-surface';
import { TabBarButton } from '@/components/tab-bar-button';
import { TabBarLabel } from '@/components/tab-bar-label';
import { BlurBar } from '@/components/ui/blur-bar';
+import { Text } from '@/components/ui/text';
import { FEATURE_FLAG_QUICK_CHAT, useFeatureFlag } from '@/lib/analytics/posthog';
import { usePendingAppAction } from '@/lib/app-actions/use-pending-app-action';
import { PROFILE_TAB_ROOT } from '@/lib/finding-detail-back';
@@ -30,9 +31,11 @@ import {
getTabBarIconSize,
shouldHideTabBar,
shouldShowTabLabel,
+ TAB_LABEL_MINIMUM_FONT_SCALE,
TAB_LABEL_WRAP_FONT_SCALE,
tabAccessibilityLabel,
tabBarPosition,
+ tabLabelLineCount,
visibleTabCount,
} from '@/lib/tab-bar-layout';
@@ -56,6 +59,48 @@ function TabBarBackground() {
);
}
+/**
+ * One tab label, on the lines the bar reserves for it (`tabLabelLineCount`).
+ * On the single reserved line a label too wide for its tab would wrap into a
+ * second line the bar has no height for and render clipped at the bar's edge
+ * ("PROFIL E" at 160 dp, e1, 2026-09-21), so the label is pinned to one line and
+ * asks the platform to shrink it to fit (`adjustsFontSizeToFit`, implemented on
+ * both iOS and Android) and to tail-truncate whatever still overflows. That
+ * shrink is a residual guard, not the legibility rule: the window-width rule
+ * (`shouldShowTabLabel`) drops the labels whenever a full-size label would not
+ * fit its tab, so a label that renders never has to shrink that far on either
+ * platform. `minimumFontScale` caps the residual shrink on iOS only — Android's
+ * Fabric autosize floors at RN's own platform minimum. Where the bar does
+ * reserve a second line (the wrap font scale) the shared `TabBarLabel` renders
+ * it: the two-line copy that carries its own break, and one truncated line for
+ * every other label.
+ */
+function TabLabel({
+ label,
+ focused,
+ allowWrap,
+}: Readonly<{ label: string; focused: boolean; allowWrap: boolean }>) {
+ if (allowWrap) {
+ return ;
+ }
+ return (
+
+ {label}
+
+ );
+}
+
export default function TabsLayout() {
const router = useRouter();
const pathname = usePathname();
@@ -63,6 +108,8 @@ export default function TabsLayout() {
const colors = useThemeColors();
const { bottom, left, right } = useSafeAreaInsets();
const { width, fontScale } = useWindowDimensions();
+ const tabLabelLines = tabLabelLineCount(fontScale);
+ const allowLabelWrap = tabLabelLines > 1;
const hideTabs = shouldHideTabBar(pathname);
const showKiloClawTab = useKiloClawTabVisible();
const showQuickChatTab = useFeatureFlag(FEATURE_FLAG_QUICK_CHAT, false);
@@ -205,7 +252,9 @@ export default function TabsLayout() {
tabBarPosition('home', tabFlags) ?? 1,
tabCount
),
- tabBarLabel: ({ focused }) => ,
+ tabBarLabel: ({ focused }) => (
+
+ ),
tabBarIcon: ({ color, focused }) => (
),
@@ -225,7 +274,9 @@ export default function TabsLayout() {
// 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 }) => ,
+ tabBarLabel: ({ focused }) => (
+
+ ),
tabBarIcon: ({ color, focused }) => (
),
@@ -250,7 +301,9 @@ export default function TabsLayout() {
tabBarPosition('agents', tabFlags) ?? 2,
tabCount
),
- tabBarLabel: ({ focused }) => ,
+ tabBarLabel: ({ focused }) => (
+
+ ),
tabBarIcon: ({ color, focused }) => (
),
@@ -267,7 +320,9 @@ export default function TabsLayout() {
tabBarPosition('chat', tabFlags) ?? 3,
tabCount
),
- tabBarLabel: ({ focused }) => ,
+ tabBarLabel: ({ focused }) => (
+
+ ),
tabBarIcon: ({ color, focused }) => (
),
@@ -283,7 +338,9 @@ export default function TabsLayout() {
tabCount,
tabCount
),
- tabBarLabel: ({ focused }) => ,
+ tabBarLabel: ({ focused }) => (
+
+ ),
tabBarIcon: ({ color, focused }) => (
),
diff --git a/apps/mobile/src/components/add-credits-row.mounted.test.tsx b/apps/mobile/src/components/add-credits-row.mounted.test.tsx
new file mode 100644
index 0000000000..cb5a59b0b7
--- /dev/null
+++ b/apps/mobile/src/components/add-credits-row.mounted.test.tsx
@@ -0,0 +1,75 @@
+/* eslint-disable typescript-eslint/no-deprecated -- DOM-free mounted React Native layout regression tests. */
+import { createElement, type ElementType } from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { act, TestRenderer } from '@/test/renderer';
+
+import { AddCreditsRow } from './add-credits-row';
+
+const windowDims = vi.hoisted(() => ({ width: 390, height: 844, fontScale: 1, scale: 2 }));
+const platform = vi.hoisted(() => ({ OS: 'android' as string }));
+
+vi.mock('react-native', () => ({
+ Platform: platform,
+ View: 'View',
+ useWindowDimensions: () => windowDims,
+}));
+vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));
+vi.mock('@/components/ui/button', () => ({ Button: 'Button' }));
+vi.mock('@/lib/external-link', () => ({ openExternalUrl: vi.fn() }));
+
+const BUTTON = 'Button' as ElementType;
+
+let renderer: TestRenderer.ReactTestRenderer | undefined = undefined;
+
+function renderRow() {
+ act(() => {
+ const element = createElement(AddCreditsRow, { url: 'https://example.com/credits' });
+ if (renderer) {
+ renderer.update(element);
+ } else {
+ renderer = TestRenderer.create(element);
+ }
+ });
+ if (!renderer) {
+ throw new Error('Missing AddCreditsRow renderer');
+ }
+ return renderer.root;
+}
+
+/** The row's only View: the container the copy and the button sit in. */
+function containerClasses(root: TestRenderer.ReactTestInstance): string[] {
+ return String(root.find(node => Object.is(node.type, 'View')).props.className).split(' ');
+}
+
+afterEach(() => {
+ act(() => renderer?.unmount());
+ renderer = undefined;
+ windowDims.width = 390;
+ platform.OS = 'android';
+});
+
+describe('AddCreditsRow mounted layout', () => {
+ // At 160 dp the "Add credits" button kept its natural width beside the copy
+ // and collapsed the description to a one-letter column ("A", e1, 2026-09-21).
+ it('stacks the copy above a full-width button in a narrow window', () => {
+ windowDims.width = 160;
+ const root = renderRow();
+ expect(containerClasses(root)).toContain('gap-2');
+ expect(containerClasses(root)).not.toContain('flex-row');
+ expect(String(root.findByType(BUTTON).props.className).split(' ')).toContain('w-full');
+ });
+
+ it('keeps the copy and the button on one row at phone widths', () => {
+ const root = renderRow();
+ expect(containerClasses(root)).toEqual(
+ expect.arrayContaining(['flex-row', 'items-center', 'justify-between'])
+ );
+ expect(String(root.findByType(BUTTON).props.className)).not.toContain('w-full');
+ });
+
+ it('renders nothing on iOS', () => {
+ platform.OS = 'ios';
+ expect(renderRow().findAllByType(BUTTON)).toHaveLength(0);
+ });
+});
diff --git a/apps/mobile/src/components/add-credits-row.tsx b/apps/mobile/src/components/add-credits-row.tsx
index c60863f328..65c4cf7537 100644
--- a/apps/mobile/src/components/add-credits-row.tsx
+++ b/apps/mobile/src/components/add-credits-row.tsx
@@ -1,9 +1,10 @@
import { useTranslation } from 'react-i18next';
-import { Platform, View } from 'react-native';
+import { Platform, useWindowDimensions, View } from 'react-native';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { openExternalUrl } from '@/lib/external-link';
+import { isNarrowLayout } from '@/lib/narrow-layout';
import { cn } from '@/lib/utils';
type AddCreditsRowProps = Readonly<{
@@ -14,6 +15,11 @@ type AddCreditsRowProps = Readonly<{
/** Zero-balance CTA row: muted copy + an "Add credits" button to the web billing page. */
export function AddCreditsRow({ url, className }: AddCreditsRowProps) {
const { t } = useTranslation();
+ const { width } = useWindowDimensions();
+ // The button keeps its natural width, so in a narrow window the description
+ // beside it collapses to a column of single letters ("A", 160 dp, e1,
+ // 2026-09-21). Stacking gives the copy the row's full width.
+ const narrow = isNarrowLayout(width);
// App Store review: iOS must not show an in-app CTA that opens an external
// purchase/billing page. Credits are managed on the web there, so this row is
// Android-only — gate it here so no call site can surface it on iOS.
@@ -21,13 +27,14 @@ export function AddCreditsRow({ url, className }: AddCreditsRowProps) {
return null;
}
return (
-
-
+
+
{t('addCredits.description')}