Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -333,8 +333,12 @@ export function NewSessionConfigureForm({
);

return (
// The root reserves the navigation-bar inset, so the keyboard-lift view
// pads from its own bottom edge and must not add the inset again.
<View className="flex-1 bg-background" style={{ paddingBottom: bottom }}>
<AppAwareKeyboardPaddingView className="flex-1">{body}</AppAwareKeyboardPaddingView>
<AppAwareKeyboardPaddingView className="flex-1" containerReservesBottomInset>
{body}
</AppAwareKeyboardPaddingView>
</View>
);
}
4 changes: 3 additions & 1 deletion apps/mobile/src/components/agents/session-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1921,7 +1921,9 @@ export function SessionDetailContent({
{keepScreenAwake ? <ActiveSessionKeepAwake sessionId={sessionId} /> : null}

{keyboardContainerKind === 'app-aware-padding' ? (
<AppAwareKeyboardPaddingView className="flex-1">
// The trailing bottom-chrome spacer below reserves the navigation-
// bar inset outside this view, so the view must not add it again.
<AppAwareKeyboardPaddingView className="flex-1" containerReservesBottomInset>
{renderKeyboardBody()}
</AppAwareKeyboardPaddingView>
) : (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,35 @@
import { type ElementType } from 'react';
import { afterEach, beforeEach, expect, it } from 'vitest';
import type * as AppAwareKeyboardPadding from '@/components/kilo-chat/app-aware-keyboard-padding';
import { act } from '@/test/renderer';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';

import {
keyboard,
mount,
platform,
resetUnlockMocks,
route,
unlockRoot,
unmountUnlock,
} from '@/components/app-unlock-screen.test-helpers';
import { getEffectiveTabBarHeight } from '@/lib/tab-bar-layout';
import { MIN_BOTTOM_CHROME_HEIGHT, TOAST_BOTTOM_GAP } from '@/lib/toast-offset';

// One keyboard read for the whole app: the Toaster must import the hook the
// screens reserve padding with, not run its own listener pair, or the toast's
// height can drift from theirs. The counter is a plain object so
// `resetUnlockMocks` (which resets every `vi.fn`) cannot clear it.
const sharedKeyboardHook = vi.hoisted(() => ({ calls: 0 }));
vi.mock('@/components/kilo-chat/app-aware-keyboard-padding', async importOriginal => {
const actual = await importOriginal<typeof AppAwareKeyboardPadding>();
return {
...actual,
useAppAwareKeyboardPadding: () => {
sharedKeyboardHook.calls += 1;
return actual.useAppAwareKeyboardPadding();
},
};
});

beforeEach(resetUnlockMocks);
afterEach(unmountUnlock);
Expand All @@ -31,3 +54,169 @@ it('anchors the toast container to the window so toasts reach the accessibility
expect(toasters).toHaveLength(1);
expect(toasters[0]?.props.positionerStyle).toEqual({ top: 0 });
});

/**
* The safe-area inset is not a reliable floor for the bottom chrome: on Android
* it is only `navigationBars()`, it does not grow while the IME's navigation
* row is on screen, and it can be reported as `0`. A toast anchored to the
* inset alone had its last line clipped under that chrome (2026-09-18 device
* finding), so the offset is floored at one shared bottom-chrome height for
* both platforms — no platform branch.
*/
it('floors the toast offset at the shared bottom-chrome height', async () => {
platform.OS = 'android';
// Default route: a screen pushed over the tabs, so no tab bar is on screen
// and the bottom-chrome floor decides the offset.
await mount();

const toasters = unlockRoot().findAllByType('Toaster' as ElementType);

// The mocked inset (12) is below the floor, so the floor decides the offset.
expect(toasters[0]?.props.offset).toBe(MIN_BOTTOM_CHROME_HEIGHT + TOAST_BOTTOM_GAP);
});

/**
* The floating tab bar is an absolute overlay over the screen bottom: the
* reported bottom inset does not include it, and a toast anchored to the
* inset landed over the tab icons (2026-09-19 visual spot check, p1 — the
* manual-review error toast covered the navigation row). While a tab screen
* is on top, the offset must clear the bar's full rendered height.
*/
it('clears the floating tab bar while a tab screen is on top', async () => {
platform.OS = 'android';
route.segments = ['(app)', '(tabs)', '(3_profile)', 'code-reviewer', 'personal', 'manual-review'];
route.pathname = '/code-reviewer/personal/manual-review';
await mount();

const toasters = unlockRoot().findAllByType('Toaster' as ElementType);

// Same predicate and height source as the tab bar's own layout
// (`(tabs)/_layout.tsx`), so the toast clears whatever the bar renders.
const tabOverlayHeight = getEffectiveTabBarHeight({
bottomInset: 12,
platform: 'android',
fontScale: 1,
});
expect(toasters[0]?.props.offset).toBe(tabOverlayHeight + TOAST_BOTTOM_GAP);
});

/**
* The bar's own layout hides it on two in-tab routes (`shouldHideTabBar`);
* there the toast must fall back to the resting offset instead of floating
* over a bar that is not there.
*/
it('keeps the resting offset when the route hides the tab bar', async () => {
platform.OS = 'android';
route.segments = ['(app)', '(tabs)', '(1_kiloclaw)', 'chat', 'sandbox', 'conversation'];
route.pathname = '/chat/sandbox/conversation';
await mount();

const toasters = unlockRoot().findAllByType('Toaster' as ElementType);

expect(toasters[0]?.props.offset).toBe(MIN_BOTTOM_CHROME_HEIGHT + TOAST_BOTTOM_GAP);
});

/**
* The keyboard read lives in one hook (`useAppAwareKeyboardPadding`) so the
* padding view and the reveal hook cannot disagree with the toast. The Toaster
* imports that hook instead of running its own `Keyboard`/`AppState` listener
* pair; this test fails if a second implementation grows back here.
*/
it('reads the keyboard height through the shared app-aware hook', async () => {
sharedKeyboardHook.calls = 0;

await mount();

expect(sharedKeyboardHook.calls).toBeGreaterThan(0);
});

/**
* Android's `endCoordinates.height` stops at the navigation bar
* (`ReactRootView` sends `imeInsets.bottom − barInsets.bottom`), so the raw
* height sits below the IME's true top edge by the bar inset. The toast is
* anchored to the screen bottom, so the Toaster resolves the occlusion through
* the same rule the screens reserve padding with (`resolveKeyboardBottomPadding`)
* — a raw height left the toast's last line behind the IME's navigation row
* (2026-09-20 review finding). The mocked bottom inset is 12.
*/
it('clears the Android IME navigation row by adding the bottom inset', async () => {
platform.OS = 'android';
await mount();

act(() => {
keyboard.show({ endCoordinates: { height: 300 } });
});

const toasters = unlockRoot().findAllByType('Toaster' as ElementType);

expect(toasters[0]?.props.offset).toBe(300 + 12 + TOAST_BOTTOM_GAP);
});

/**
* iOS reports the keyboard window frame, which reaches the screen bottom and
* so already includes the home-indicator inset. Adding the bottom inset there
* would float the toast above the keyboard, so the iOS height passes through
* unchanged — the platform-parity half of the keyboard rule.
*/
it('keeps the iOS keyboard height, which already reaches the screen bottom', async () => {
platform.OS = 'ios';
await mount();

act(() => {
keyboard.show({ endCoordinates: { height: 300 } });
});

const toasters = unlockRoot().findAllByType('Toaster' as ElementType);

expect(toasters[0]?.props.offset).toBe(300 + TOAST_BOTTOM_GAP);
});

/**
* The harness keeps every keyboard subscriber, one set per direction (see the
* Keyboard mock in the test helpers). A second consumer — here an extra
* listener standing in for a screen on top of the Toaster — must not shadow the
* Toaster's listener, and disposing it must not detach the Toaster's. A single
* slot per direction failed both halves.
*/
it('delivers a keyboard event to every subscriber and detaches only the disposed one', async () => {
platform.OS = 'android';
await mount();

const extra = vi.fn((_event: { endCoordinates: { height: number } }) => undefined);
const subscription = keyboard.addListener('keyboardDidShow', extra);

act(() => {
keyboard.show({ endCoordinates: { height: 300 } });
});
// Both the Toaster's hook and the extra subscriber received the height.
expect(extra).toHaveBeenCalledWith({ endCoordinates: { height: 300 } });
expect(unlockRoot().findAllByType('Toaster' as ElementType)[0]?.props.offset).toBe(
300 + 12 + TOAST_BOTTOM_GAP
);

subscription.remove();
act(() => {
keyboard.show({ endCoordinates: { height: 240 } });
});
// The disposed subscriber is gone; the Toaster's listener is still attached.
expect(extra).toHaveBeenCalledTimes(1);
expect(unlockRoot().findAllByType('Toaster' as ElementType)[0]?.props.offset).toBe(
240 + 12 + TOAST_BOTTOM_GAP
);
});

/**
* The same rule runs on iOS: the offset module reads no platform, so the
* resting offset is the shared bottom-chrome floor plus the standard gap, not
* the raw iOS inset (12 here). This is the platform-parity assertion for the
* toast path — if a per-platform branch grows back, one of the two platforms
* stops matching the shared floor.
*/
it('uses the same bottom-chrome floor on iOS', async () => {
platform.OS = 'ios';
await mount();

const toasters = unlockRoot().findAllByType('Toaster' as ElementType);

expect(toasters[0]?.props.offset).toBe(MIN_BOTTOM_CHROME_HEIGHT + TOAST_BOTTOM_GAP);
});
117 changes: 96 additions & 21 deletions apps/mobile/src/components/app-root-providers.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { ActionSheetProvider } from '@expo/react-native-action-sheet';
import { PortalHost } from '@rn-primitives/portal';
import { QueryClientProvider } from '@tanstack/react-query';
import { usePathname, useSegments } from 'expo-router';
import { CheckCircle2, Info, Loader, TriangleAlert, XCircle } from '@/components/ui/icons';
import { type ReactNode } from 'react';
import { Platform, useWindowDimensions } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Toaster } from 'sonner-native';
import { useTranslation } from 'react-i18next';

import { AppUnlockAnnouncements } from '@/components/app-unlock-screen';
import { useAppAwareKeyboardPadding } from '@/components/kilo-chat/app-aware-keyboard-padding';
import { resolveKeyboardBottomPadding } from '@/components/login-screen-state';
import { OfflineBanner } from '@/components/offline-banner';
import { AppUnlockProvider } from '@/lib/app-unlock-context';
import { AuthProvider } from '@/lib/auth/auth-context';
Expand All @@ -16,6 +21,8 @@ import { OrganizationProvider } from '@/lib/organization-context';
import { queryClient } from '@/lib/query-client';
import { QueryClientNativeLifecycle } from '@/lib/query-client-lifecycle';
import { ToolSummaryTranslationRuntimeBootstrap } from '@/lib/tool-summary-translation/tool-summary-translation-preference';
import { getEffectiveTabBarHeight, shouldHideTabBar } from '@/lib/tab-bar-layout';
import { getToastBottomOffset } from '@/lib/toast-offset';
import { trpcClient, TRPCProvider } from '@/lib/trpc';

/**
Expand Down Expand Up @@ -44,7 +51,6 @@ export function AppRootProviders({
readonly children: ReactNode;
readonly languageReady: boolean;
}) {
const colors = useThemeColors();
const { t } = useTranslation();

return (
Expand Down Expand Up @@ -79,26 +85,7 @@ export function AppRootProviders({
lifetime (spot check e4-end). Bottom is the transient-message convention:
a toast may cover the composer briefly, never the navigation.
*/}
<Toaster
position="bottom-center"
positionerStyle={TOAST_POSITIONER_STYLE}
icons={{
success: <CheckCircle2 size={20} color={colors.good} />,
error: <XCircle size={20} color={colors.destructive} />,
warning: <TriangleAlert size={20} color={colors.warn} />,
info: <Info size={20} color={colors.mutedForeground} />,
loading: <Loader size={20} color={colors.mutedForeground} />,
}}
toastOptions={{
style: {
backgroundColor: colors.card,
borderColor: colors.border,
borderWidth: 1,
},
titleStyle: { color: colors.foreground },
descriptionStyle: { color: colors.mutedForeground },
}}
/>
<AppToaster />
</>
</ActionSheetProvider>
</OrganizationProvider>
Expand All @@ -109,3 +96,91 @@ export function AppRootProviders({
</GestureHandlerRootView>
);
}

/**
* The Toaster reads the keyboard through the shared
* `useAppAwareKeyboardPadding` hook, so its height cannot drift from the one
* the screens reserve. It is called here, in a child of `AppRootProviders`,
* so a keyboard show/hide re-renders only the Toaster, never the app tree the
* provider wraps.
*
* Android needs this even though the app is edge-to-edge: under API 35+ the
* window never resizes for the IME (`login-screen.tsx`), and
* `react-native-safe-area-context` does not report IME insets, so a
* bottom-anchored overlay has no other way to clear the keyboard and its
* navigation row.
*
* The offset is one platform-free rule (`lib/toast-offset.ts`): iOS and Android
* run the same math, and the platform enters only through the values resolved
* here for it — the tab bar's own rendered height, which the bar's helper owns,
* and the keyboard occlusion's origin (`resolveKeyboardBottomPadding`).
*/
function AppToaster() {
const colors = useThemeColors();
const { bottom } = useSafeAreaInsets();
const { fontScale } = useWindowDimensions();
const keyboardHeight = useAppAwareKeyboardPadding();
// The hook's height is the platform's own keyboard metric, and the two
// platforms measure it from different origins: Android's stops at the
// navigation bar (`ReactRootView` reports `imeInsets.bottom − barInsets.bottom`),
// while iOS reports the keyboard frame, which reaches the screen bottom. The
// offset is anchored to the screen bottom, so the occlusion is resolved here
// with the same rule the screens reserve padding with
// (`resolveKeyboardBottomPadding`); passing the raw Android height left the
// toast's last line behind the IME's navigation row (2026-09-20 review
// finding). `lib/toast-offset.ts` stays platform-free.
const keyboardOcclusion =
keyboardHeight > 0
? resolveKeyboardBottomPadding({ keyboardHeight, bottomInset: bottom, platform: Platform.OS })
: 0;
const segments = useSegments();
const pathname = usePathname();
// The floating tab bar is an absolute overlay over the screen bottom, so it
// never appears in the reported bottom inset and a toast anchored to the
// inset landed over the tab icons (2026-09-19 visual spot check, p1). It
// renders exactly when the focused route sits inside the tabs navigator and
// `shouldHideTabBar` does not hide it — the same predicate the bar's own
// layout uses — so the toast clears it only while it is actually on screen;
// a screen pushed over the tabs (agent-chat, pr-review) or the auth flow
// keeps the toast at its resting offset. `getEffectiveTabBarHeight` is the
// bar's own measurement (it carries the bar's small Android-only extra
// padding), so the toast cannot disagree with what the bar renders. See
// `lib/toast-offset.ts`.
const tabBarHeight =
(segments as readonly string[]).includes('(tabs)') && !shouldHideTabBar(pathname)
? getEffectiveTabBarHeight({ bottomInset: bottom, platform: Platform.OS, fontScale })
: 0;

return (
<Toaster
position="bottom-center"
// Explicit offset, rather than sonner-native's `safe area inset + 8`:
// the reported inset can under-report the bottom chrome (Android's IME
// navigation row), which left the error toast's last line clipped under
// it, and never covers the floating tab bar, which the toast then
// covered. One platform-free rule; see `lib/toast-offset.ts`.
offset={getToastBottomOffset({
safeAreaBottom: bottom,
keyboardHeight: keyboardOcclusion,
tabBarHeight,
})}
positionerStyle={TOAST_POSITIONER_STYLE}
icons={{
success: <CheckCircle2 size={20} color={colors.good} />,
error: <XCircle size={20} color={colors.destructive} />,
warning: <TriangleAlert size={20} color={colors.warn} />,
info: <Info size={20} color={colors.mutedForeground} />,
loading: <Loader size={20} color={colors.mutedForeground} />,
}}
toastOptions={{
style: {
backgroundColor: colors.card,
borderColor: colors.border,
borderWidth: 1,
},
titleStyle: { color: colors.foreground },
descriptionStyle: { color: colors.mutedForeground },
}}
/>
);
}
Loading
Loading