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
5 changes: 4 additions & 1 deletion apps/mobile/plugins/branded-splash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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'],
Expand Down
75 changes: 47 additions & 28 deletions apps/mobile/src/app/(app)/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
shouldShowNeedsInput,
useSessionAttentionRevision,
} from '@/lib/session-attention';
import { TabBarLabelContext } from '@/lib/tab-bar-clearance';
import {
getEffectiveTabBarHeight,
getTabBarHorizontalInset,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = (
<StateSurfaceInsets bottomInset={hideTabs ? 0 : tabBarHeight}>
<Tabs
screenOptions={{
Expand Down Expand Up @@ -167,7 +197,7 @@ export default function TabsLayout() {
tabBarPosition('home', tabFlags) ?? 1,
tabCount
),
tabBarLabel: ({ focused }) => <TabBarLabel label={t('tabs.home')} focused={focused} />,
tabBarLabel: ({ focused }) => <TabBarLabel label={homeLabel} focused={focused} />,
tabBarIcon: ({ color, focused }) => (
<House size={tabIconSize} color={color} strokeWidth={focused ? 2 : 1.5} />
),
Expand All @@ -184,16 +214,10 @@ export default function TabsLayout() {
tabBarPosition('kiloclaw', tabFlags) ?? 2,
tabCount
),
tabBarLabel: ({ focused }) => (
<TabBarLabel
label={
fontScale > 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 }) => <TabBarLabel label={kiloclawLabel} focused={focused} />,
tabBarIcon: ({ color, focused }) => (
<MessageSquare size={tabIconSize} color={color} strokeWidth={focused ? 2 : 1.5} />
),
Expand All @@ -218,9 +242,7 @@ export default function TabsLayout() {
tabBarPosition('agents', tabFlags) ?? 2,
tabCount
),
tabBarLabel: ({ focused }) => (
<TabBarLabel label={t('common.agents')} focused={focused} />
),
tabBarLabel: ({ focused }) => <TabBarLabel label={agentsLabel} focused={focused} />,
tabBarIcon: ({ color, focused }) => (
<Bot size={tabIconSize} color={color} strokeWidth={focused ? 2 : 1.5} />
),
Expand All @@ -237,9 +259,7 @@ export default function TabsLayout() {
tabBarPosition('chat', tabFlags) ?? 3,
tabCount
),
tabBarLabel: ({ focused }) => (
<TabBarLabel label={t('common.chat')} focused={focused} />
),
tabBarLabel: ({ focused }) => <TabBarLabel label={chatLabel} focused={focused} />,
tabBarIcon: ({ color, focused }) => (
<MessageCircle size={tabIconSize} color={color} strokeWidth={focused ? 2 : 1.5} />
),
Expand All @@ -255,9 +275,7 @@ export default function TabsLayout() {
tabCount,
tabCount
),
tabBarLabel: ({ focused }) => (
<TabBarLabel label={t('common.profile')} focused={focused} />
),
tabBarLabel: ({ focused }) => <TabBarLabel label={profileLabel} focused={focused} />,
tabBarIcon: ({ color, focused }) => (
<UserRound size={tabIconSize} color={color} strokeWidth={focused ? 2 : 1.5} />
),
Expand All @@ -273,4 +291,5 @@ export default function TabsLayout() {
</Tabs>
</StateSurfaceInsets>
);
return <TabBarLabelContext value={showTabLabel}>{tabsLayout}</TabBarLabelContext>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
21 changes: 7 additions & 14 deletions apps/mobile/src/components/agents/session-list-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
23 changes: 6 additions & 17 deletions apps/mobile/src/components/agents/session-list-screen.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';
Expand All @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<SessionListSearchHeader {...baseProps} />);
expect(searchInput(renderer).props.numberOfLines).toBe(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 5 additions & 10 deletions apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -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(
() =>
({
Expand Down
Loading
Loading