Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 62 additions & 5 deletions apps/mobile/src/app/(app)/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand All @@ -56,13 +59,57 @@ 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 <TabBarLabel label={label} focused={focused} />;
}
return (
<Text
accessible={false}
className={
focused
? 'w-full text-center font-mono-medium text-[11px] leading-4 uppercase tracking-[0.2px] text-foreground'
: 'w-full text-center font-mono-medium text-[11px] leading-4 uppercase tracking-[0.2px] text-muted-foreground'
}
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={TAB_LABEL_MINIMUM_FONT_SCALE}
ellipsizeMode="tail"
>
{label}
</Text>
);
}

export default function TabsLayout() {
const router = useRouter();
const pathname = usePathname();
const segments = useSegments();
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);
Expand Down Expand Up @@ -205,7 +252,9 @@ export default function TabsLayout() {
tabBarPosition('home', tabFlags) ?? 1,
tabCount
),
tabBarLabel: ({ focused }) => <TabBarLabel label={homeLabel} focused={focused} />,
tabBarLabel: ({ focused }) => (
<TabLabel label={homeLabel} focused={focused} allowWrap={allowLabelWrap} />
),
tabBarIcon: ({ color, focused }) => (
<House size={tabIconSize} color={color} strokeWidth={focused ? 2 : 1.5} />
),
Expand All @@ -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 label={kiloclawLabel} focused={focused} />,
tabBarLabel: ({ focused }) => (
<TabLabel label={kiloclawLabel} focused={focused} allowWrap={allowLabelWrap} />
),
tabBarIcon: ({ color, focused }) => (
<MessageSquare size={tabIconSize} color={color} strokeWidth={focused ? 2 : 1.5} />
),
Expand All @@ -250,7 +301,9 @@ export default function TabsLayout() {
tabBarPosition('agents', tabFlags) ?? 2,
tabCount
),
tabBarLabel: ({ focused }) => <TabBarLabel label={agentsLabel} focused={focused} />,
tabBarLabel: ({ focused }) => (
<TabLabel label={agentsLabel} focused={focused} allowWrap={allowLabelWrap} />
),
tabBarIcon: ({ color, focused }) => (
<Bot size={tabIconSize} color={color} strokeWidth={focused ? 2 : 1.5} />
),
Expand All @@ -267,7 +320,9 @@ export default function TabsLayout() {
tabBarPosition('chat', tabFlags) ?? 3,
tabCount
),
tabBarLabel: ({ focused }) => <TabBarLabel label={chatLabel} focused={focused} />,
tabBarLabel: ({ focused }) => (
<TabLabel label={chatLabel} focused={focused} allowWrap={allowLabelWrap} />
),
tabBarIcon: ({ color, focused }) => (
<MessageCircle size={tabIconSize} color={color} strokeWidth={focused ? 2 : 1.5} />
),
Expand All @@ -283,7 +338,9 @@ export default function TabsLayout() {
tabCount,
tabCount
),
tabBarLabel: ({ focused }) => <TabBarLabel label={profileLabel} focused={focused} />,
tabBarLabel: ({ focused }) => (
<TabLabel label={profileLabel} focused={focused} allowWrap={allowLabelWrap} />
),
tabBarIcon: ({ color, focused }) => (
<UserRound size={tabIconSize} color={color} strokeWidth={focused ? 2 : 1.5} />
),
Expand Down
75 changes: 75 additions & 0 deletions apps/mobile/src/components/add-credits-row.mounted.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
13 changes: 10 additions & 3 deletions apps/mobile/src/components/add-credits-row.tsx
Original file line number Diff line number Diff line change
@@ -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<{
Expand All @@ -14,20 +15,26 @@ 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.
if (Platform.OS === 'ios') {
return null;
}
return (
<View className={cn('flex-row items-center justify-between', className)}>
<Text className="flex-1 pr-3 text-xs text-muted-foreground">
<View className={cn(narrow ? 'gap-2' : 'flex-row items-center justify-between', className)}>
<Text className={cn(narrow ? undefined : 'flex-1 pr-3', 'text-xs text-muted-foreground')}>
{t('addCredits.description')}
</Text>
<Button
size="sm"
variant="outline"
className={narrow ? 'w-full' : undefined}
onPress={() => {
void openExternalUrl(url, { label: t('addCredits.billingPage') });
}}
Expand Down
Loading
Loading