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
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,10 @@ 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">
<AppAwareKeyboardPaddingView className="flex-1" containerReservesBottomInset>
{body}
{/*
The primary action is pinned below the scroll body, never part of it.
Expand Down
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 @@ -1959,7 +1959,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
Expand Up @@ -130,19 +130,81 @@ it('reads the keyboard height through the shared app-aware hook', async () => {
expect(sharedKeyboardHook.calls).toBeGreaterThan(0);
});

it('keeps the toast above the software keyboard while it is up', async () => {
/**
* 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 } });
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
Expand Down
23 changes: 19 additions & 4 deletions apps/mobile/src/components/app-root-providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ 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 Down Expand Up @@ -109,15 +110,29 @@ export function AppRootProviders({
* bottom-anchored overlay has no other way to clear the keyboard and its
* navigation row.
*
* The resting offset is one platform-free rule (`lib/toast-offset.ts`): iOS and
* Android run the same math, and the only platform value it reads is the tab
* bar's own rendered height, which the bar's helper owns.
* 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
Expand Down Expand Up @@ -146,7 +161,7 @@ function AppToaster() {
// covered. One platform-free rule; see `lib/toast-offset.ts`.
offset={getToastBottomOffset({
safeAreaBottom: bottom,
keyboardHeight,
keyboardHeight: keyboardOcclusion,
tabBarHeight,
})}
positionerStyle={TOAST_POSITIONER_STYLE}
Expand Down
8 changes: 4 additions & 4 deletions apps/mobile/src/components/app-unlock-screen.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,9 @@ it.each([false, true])(
native.authenticateAsync.mockResolvedValueOnce({ success: false, error: 'user_cancel' });
const now = vi.spyOn(Date, 'now').mockReturnValue(0);
await flush(() => {
lifecycle.change?.('background');
lifecycle.change('background');
now.mockReturnValue(300_000);
lifecycle.change?.('active');
lifecycle.change('active');
});
expectHidden(root(), true);
await flush(retry()?.props.onPress as () => void);
Expand Down Expand Up @@ -263,9 +263,9 @@ describe.each(['ios', 'android'])('%s shared unlock announcements', os => {
if (locked) {
const now = vi.spyOn(Date, 'now').mockReturnValue(0);
await flush(() => {
lifecycle.change?.('background');
lifecycle.change('background');
now.mockReturnValue(300_000);
lifecycle.change?.('active');
lifecycle.change('active');
});
expect(retry()?.props.disabled).toBe(true);
}
Expand Down
88 changes: 61 additions & 27 deletions apps/mobile/src/components/app-unlock-screen.test-helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,61 @@ const route = vi.hoisted(() => ({
segments: ['(app)', 'agent-chat'] as string[],
pathname: '/agent-chat',
}));
const lifecycle = vi.hoisted(() => ({
change: undefined as ((state: AppStateStatus) => void) | undefined,
}));
const keyboard = vi.hoisted(() => ({
// Both slots share one signature so either listener can be stored; `hide`
// is dispatched with an empty payload.
show: undefined as ((event: { endCoordinates: { height: number } }) => void) | undefined,
hide: undefined as ((event: { endCoordinates: { height: number } }) => void) | undefined,
}));
// AppState has more than one subscriber on this screen (the query client
// lifecycle and the keyboard-lift hooks), so the harness keeps every listener
// and broadcasts to all of them. A single slot let the last registration
// shadow the earlier ones, which hid the stale-lift defect these mounted tests
// exist to catch. Ported from the closed #6392.
const lifecycle = vi.hoisted(() => {
const listeners = new Set<(state: AppStateStatus) => void>();
return {
listeners,
change: (state: AppStateStatus) => {
for (const listener of listeners) {
listener(state);
}
},
};
});
const keyboard = vi.hoisted(() => {
// Keyboard has more than one subscriber in production: `AppRootProviders`
// mounts the Toaster's shared keyboard hook alongside a screen's, so the mock
// keeps every listener in a set per direction. One slot per direction let the
// last registration shadow the earlier ones, and a `remove()` that cleared
// both slots detached a listener it did not own. The AppState mock below is a
// set for the same reason. Same signature for both directions; `hide` is
// dispatched with an empty payload.
const showListeners = new Set<(event: { endCoordinates: { height: number } }) => void>();
const hideListeners = new Set<(event: { endCoordinates: { height: number } }) => void>();
const addListener = (
event: string,
listener: (event: { endCoordinates: { height: number } }) => void
) => {
const listeners =
event === 'keyboardDidShow' || event === 'keyboardWillShow' ? showListeners : hideListeners;
listeners.add(listener);
return {
remove: () => {
listeners.delete(listener);
},
};
};
return {
addListener,
showListeners,
hideListeners,
show: (event: { endCoordinates: { height: number } }) => {
for (const listener of showListeners) {
listener(event);
}
},
hide: () => {
for (const listener of hideListeners) {
listener({ endCoordinates: { height: 0 } });
}
},
};
});
export { announcements, catalogs, keyboard, lifecycle, native, platform, route, storage };
vi.mock('@/i18n/catalogs', () => ({ CATALOG_LOADERS: catalogs }));
vi.mock('expo-local-authentication', () => native);
Expand All @@ -90,30 +136,15 @@ vi.mock('react-native', () => ({
I18nManager: { isRTL: false },
AccessibilityInfo: { announceForAccessibility: announcements },
Keyboard: {
addListener: (
event: string,
listener: (event: { endCoordinates: { height: number } }) => void
) => {
if (event === 'keyboardDidShow' || event === 'keyboardWillShow') {
keyboard.show = listener;
} else {
keyboard.hide = listener;
}
return {
remove: () => {
keyboard.show = undefined;
keyboard.hide = undefined;
},
};
},
addListener: keyboard.addListener,
},
AppState: {
currentState: 'active',
addEventListener: (_event: string, listener: (state: AppStateStatus) => void) => {
lifecycle.change = listener;
lifecycle.listeners.add(listener);
return {
remove: () => {
lifecycle.change = undefined;
lifecycle.listeners.delete(listener);
},
};
},
Expand Down Expand Up @@ -327,6 +358,9 @@ export function resetUnlockMocks() {
platform.OS = 'ios';
route.segments = ['(app)', 'agent-chat'];
route.pathname = '/agent-chat';
lifecycle.listeners.clear();
keyboard.showListeners.clear();
keyboard.hideListeners.clear();
storage.getItemAsync.mockResolvedValue('enabled');
native.hasHardwareAsync.mockResolvedValue(true);
native.isEnrolledAsync.mockResolvedValue(true);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,39 @@
import { describe, expect, it } from 'vitest';

import { resolveKeyboardBottomPadding } from '@/components/login-screen-state';
import {
resolveAppAwareKeyboardPadding,
resolveKeyboardPaddingEventsForPlatform,
} from './app-aware-keyboard-padding-state';

// Ported from the closed #6380, whose cases covered the platform-aware bottom
// occlusion the keeper #6388 resolves in `resolveKeyboardBottomPadding`: the
// reported geometry is the platform capability that differs, so Android adds
// the system-bar inset to reach the keyboard's top edge while iOS keeps the
// keyboard frame height, which already reaches the window bottom.
describe('platform-aware keyboard bottom occlusion', () => {
it('reaches the keyboard top edge on Android by adding the system-bar inset', () => {
expect(
resolveKeyboardBottomPadding({ platform: 'android', keyboardHeight: 704, bottomInset: 63 })
).toBe(767);
});

it('keeps the iOS height, which already reaches the window bottom', () => {
expect(
resolveKeyboardBottomPadding({ platform: 'ios', keyboardHeight: 300, bottomInset: 34 })
).toBe(300);
});

it('reserves the system-bar inset alone while the keyboard is hidden', () => {
expect(
resolveKeyboardBottomPadding({ platform: 'android', keyboardHeight: 0, bottomInset: 63 })
).toBe(63);
expect(
resolveKeyboardBottomPadding({ platform: 'ios', keyboardHeight: 0, bottomInset: 34 })
).toBe(34);
});
});

describe('app-aware keyboard padding state', () => {
it('resolves Android keyboard events from did-show and did-hide notifications', () => {
expect(resolveKeyboardPaddingEventsForPlatform('android')).toEqual({
Expand All @@ -20,7 +49,7 @@ describe('app-aware keyboard padding state', () => {
});
});

it('clears keyboard padding when the keyboard hides or the app leaves active state', () => {
it('clears keyboard padding when the keyboard hides or the app leaves the foreground', () => {
expect(
resolveAppAwareKeyboardPadding({
currentPadding: 0,
Expand All @@ -40,4 +69,23 @@ describe('app-aware keyboard padding state', () => {
})
).toBe(0);
});

it('keeps keyboard padding through a transient iOS inactive state', () => {
// iOS reports `inactive` for Control Center, the app switcher, a call
// banner, or a system alert while the keyboard stays up, and fires no new
// `keyboardWillShow` when it returns to `active`. Collapsing the padding
// there left the login action under an open keyboard.
expect(
resolveAppAwareKeyboardPadding({
currentPadding: 320,
event: { type: 'app-state-change', appState: 'inactive' },
})
).toBe(320);
expect(
resolveAppAwareKeyboardPadding({
currentPadding: 320,
event: { type: 'app-state-change', appState: 'active' },
})
).toBe(320);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,13 @@ export function resolveAppAwareKeyboardPadding({
if (event.type === 'keyboard-hidden') {
return 0;
}
if (event.appState !== 'active') {
// iOS reports `inactive` for transient interruptions the keyboard survives —
// Control Center, the app-switcher preview, a call banner, a system
// permission alert — and fires no fresh `keyboardWillShow` on the way back to
// `active`. Dropping the padding there left the resolved occlusion stuck at 0
// under an open keyboard, so only a real backgrounding (which dismisses the
// keyboard) clears it.
if (event.appState === 'background') {
return 0;
}
return currentPadding;
Expand Down
Loading
Loading