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
7 changes: 6 additions & 1 deletion apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import { Stack } from 'expo-router';
import { appUnlockScreenLayout } from '@/components/app-unlock-screen';
import { useFormSheetDetents } from '@/lib/form-sheet';

export const unstable_settings = {
initialRouteName: 'index',
};

export default function KiloClawLayout() {
// Cap the full detent like every other formSheet: PickerSheet's header drops
// the top clearance ("bottom-form-sheet") on both platforms, which is only
// safe when the sheet cannot reach the status bar.
const { fullSheetDetent } = useFormSheetDetents();
return (
<Stack screenLayout={appUnlockScreenLayout} screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen
name="chat/instance-picker"
options={{
presentation: 'formSheet',
sheetAllowedDetents: [0.5, 1],
sheetAllowedDetents: [0.5, fullSheetDetent],
sheetGrabberVisible: true,
headerShown: false,
}}
Expand Down
9 changes: 9 additions & 0 deletions apps/mobile/src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,15 @@ export default function AppLayout() {
headerShown: false,
}}
/>
<Stack.Screen
name="voice-language-picker"
options={{
presentation: 'formSheet',
sheetAllowedDetents: [0.5, fullSheetDetent],
sheetGrabberVisible: true,
headerShown: false,
}}
/>
<Stack.Screen
name="kilo-pass"
options={{
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/src/app/(app)/voice-language-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { VoiceLanguagePickerSheet } from '@/components/voice-language-picker-sheet';

/** Route shell for the voice language picker: the sheet owns the flow and
* writes the SecureStore-backed store directly, so no picker bridge is needed. */
export default function VoiceLanguagePickerScreen() {
return <VoiceLanguagePickerSheet />;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer mounts the native tree without a DOM. */
/* eslint-disable max-lines -- the full react-native mock harness (FlatList, Platform for SheetHeader) stays inline so the picker contract reads as one screen */
import { createElement, type EffectCallback, Fragment, type ReactNode, useEffect } from 'react';
import { act } from 'react-test-renderer';
import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest';
Expand Down Expand Up @@ -27,6 +28,8 @@ type ListProps<T> = {
vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' }));
vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'ActivityIndicator' }));
vi.mock('react-native', () => ({
Platform: { OS: 'ios' },
StatusBar: { currentHeight: 0 },
FlatList: <T,>(props: ListProps<T>) =>
createElement(
'FlatList',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ vi.mock('react-native', () => ({
Switch: 'Switch',
ActivityIndicator: 'ActivityIndicator',
Platform: platform,
StatusBar: { currentHeight: 0 },
I18nManager: { isRTL: false },
AccessibilityInfo: { announceForAccessibility: announcements },
AppState: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ vi.mock('react-native', () => ({
},
Pressable: 'Pressable',
Platform: nativePlatform,
// SheetHeader reads the synchronous Android status-bar height the same way
// the form-sheet detents do (src/lib/form-sheet.ts).
StatusBar: { currentHeight: 24 },
AppState: { addEventListener: () => ({ remove: vi.fn() }) },
Alert: { alert: vi.fn() },
}));
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/components/picker-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export function PickerSheet({
doneLabel={doneLabel}
cancelLabel={cancelLabel}
disabled={disabled}
topInset="bottom-form-sheet"
/>
{headerContent}
</View>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { describe, expect, it, vi } from 'vitest';
import { SheetHeader } from './sheet-header';
import '@/i18n';

vi.mock('react-native', () => ({ Pressable: 'Pressable', View: 'View' }));
vi.mock('react-native', () => ({
Platform: { OS: 'ios' },
Pressable: 'Pressable',
StatusBar: { currentHeight: 0 },
View: 'View',
}));
// SheetHeader reads the landscape side insets; this suite mounts without a
// device, so the hook gets portrait-zero insets (same pattern as
// sheet-header.mounted.test.tsx).
Expand Down
195 changes: 183 additions & 12 deletions apps/mobile/src/components/sheet-header.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,23 @@ import { SheetHeader } from './sheet-header';
import '@/i18n';

const safeArea = vi.hoisted(() => ({ top: 0, bottom: 0, left: 0, right: 0 }));
const rn = vi.hoisted(() => ({ os: 'ios', statusHeight: 0 }));

vi.mock('react-native', () => ({
Pressable: 'Pressable',
ScrollView: 'ScrollView',
View: 'View',
I18nManager: { allowRTL: vi.fn(), isRTL: false, forceRTL: vi.fn() },
Platform: {
get OS() {
return rn.os;
},
},
StatusBar: {
get currentHeight() {
return rn.statusHeight;
},
},
}));
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => safeArea,
Expand Down Expand Up @@ -69,26 +80,46 @@ function findHeaderContainer(root: TestRenderer.ReactTestInstance): TestRenderer
}

/**
* The header row sits in an inner wrapper that carries only the landscape side
* The header row sits in an inner wrapper that carries the top and landscape side
* insets, so they add to the outer container's `px-4` gutter instead of
* overriding it. It is the only View in the header without a className.
* overriding it. Derive it from the row it wraps: a parent shell (PickerSheet)
* also renders a className-less View, so scanning for one would return the shell
* and the assertion on the wrapper's style would be vacuous.
*/
function findSideInsetWrapper(
root: TestRenderer.ReactTestInstance
): TestRenderer.ReactTestInstance {
const wrappers = root.findAll(
function findSafeAreaWrapper(root: TestRenderer.ReactTestInstance): TestRenderer.ReactTestInstance {
const row = root.find(
node =>
typeof node.type === 'string' &&
(node.type as string) === 'View' &&
node.props.className === undefined
typeof node.props.className === 'string' &&
node.props.className.includes('flex-row')
);
const wrapper = wrappers[0];
if (!wrapper) {
throw new Error('side-inset wrapper not found');
const wrapper = row.parent;
if (!wrapper || typeof wrapper.type !== 'string') {
throw new Error('safe-area wrapper not found');
}
return wrapper;
}

/**
* Mounts a bottom-form-sheet header with a resolved top inset under `os` and
* returns the inset wrapper's style, so the test can compare platforms.
*/
async function bottomFormSheetTopInsetStyle(os: string): Promise<unknown> {
rn.os = os;
rn.statusHeight = 48;
safeArea.top = 59;
const renderer = await mount({
title: 'Voice language',
onDone: () => undefined,
onCancel: () => undefined,
topInset: 'bottom-form-sheet',
});
const style = findSafeAreaWrapper(renderer.root).props.style;
renderer.unmount();
return style;
}

function HeaderWithActionFeedback({
initialTitle = 'report.pdf',
doneLabel = 'Finish',
Expand Down Expand Up @@ -116,6 +147,8 @@ function HeaderWithActionFeedback({
describe('SheetHeader', () => {
beforeEach(() => {
Object.assign(safeArea, { top: 0, bottom: 0, left: 0, right: 0 });
rn.os = 'ios';
rn.statusHeight = 0;
});

it('renders a Share pressable in the leading slot when onShare is provided', async () => {
Expand Down Expand Up @@ -370,7 +403,7 @@ describe('SheetHeader', () => {
expect(container.props.collapsable).toBe(false);
expect(container.props.className).toContain('px-4');
expect(container.props.style).toBeUndefined();
expect(findSideInsetWrapper(renderer.root).props.style).toBeUndefined();
expect(findSafeAreaWrapper(renderer.root).props.style).toBeUndefined();

renderer.unmount();
});
Expand All @@ -391,7 +424,7 @@ describe('SheetHeader', () => {
expect(container.props.collapsable).toBe(false);
expect(container.props.className).toContain('px-4');
expect(container.props.style).toBeUndefined();
const wrapper = findSideInsetWrapper(renderer.root);
const wrapper = findSafeAreaWrapper(renderer.root);
expect(wrapper.props.style).toEqual({ paddingLeft: 47, paddingRight: 59 });
const cancel = pressablesByLabel(renderer.root, 'Cancel')[0];
const done = pressablesByLabel(renderer.root, 'Done')[0];
Expand All @@ -400,4 +433,142 @@ describe('SheetHeader', () => {

renderer.unmount();
});

it('clears the status bar when the sheet reaches the top safe area', async () => {
safeArea.top = 24;
const renderer = await mount({
title: 'Voice language',
onDone: () => undefined,
onCancel: () => undefined,
});

const container = findHeaderContainer(renderer.root);
// The safe area adds clearance rather than replacing the outer gutter.
// Cancel, the title, and Done remain together inside the protected row.
expect(container.props.className).toContain('pt-4');
const wrapper = findSafeAreaWrapper(renderer.root);
expect(wrapper.props.style).toEqual({ paddingTop: 24 });
expect(pressablesByLabel(renderer.root, 'Cancel')[0]?.parent?.parent).toBe(wrapper);
expect(pressablesByLabel(renderer.root, 'Done')[0]?.parent?.parent).toBe(wrapper);

renderer.unmount();
});

it.each(['ios', 'android'])(
'falls back to the synchronous status-bar height while the top inset is unresolved (%s)',
async os => {
rn.os = os;
rn.statusHeight = 48;
const renderer = await mount({
title: 'Voice language',
onDone: () => undefined,
onCancel: () => undefined,
});

// The frame a freshly presented sheet lays out can report top: 0; the
// synchronous status-bar height keeps the Done pill below the icons.
const wrapper = findSafeAreaWrapper(renderer.root);
expect(wrapper.props.style).toEqual({ paddingTop: 48 });

renderer.unmount();
}
);

it.each(['ios', 'android'])(
'prefers a resolved top inset over the status-bar fallback (%s)',
async os => {
rn.os = os;
rn.statusHeight = 48;
safeArea.top = 24;
const renderer = await mount({
title: 'Voice language',
onDone: () => undefined,
onCancel: () => undefined,
});

const wrapper = findSafeAreaWrapper(renderer.root);
expect(wrapper.props.style).toEqual({ paddingTop: 24 });

renderer.unmount();
}
);

it.each(['ios', 'android'])('drops the top clearance for a bottom formSheet (%s)', async os => {
// p7: a bottom-anchored sheet never draws under the status bar, so the
// resolved window inset is only a dead band above the header — one rule
// reserves nothing on either platform.
rn.os = os;
rn.statusHeight = 48;
safeArea.top = 24;
const renderer = await mount({
title: 'Voice language',
onDone: () => undefined,
onCancel: () => undefined,
topInset: 'bottom-form-sheet',
});

const wrapper = findSafeAreaWrapper(renderer.root);
expect(wrapper.props.style).toBeUndefined();

renderer.unmount();
});

it('reserves the same bottom-form-sheet top clearance regardless of Platform.OS', async () => {
// Regression for the removed platform fork: with a resolved top inset the
// old iOS branch kept 59 while Android dropped it. One implementation now
// drops it under identical inputs on both OS values.
const iosStyle = await bottomFormSheetTopInsetStyle('ios');
const androidStyle = await bottomFormSheetTopInsetStyle('android');

expect(iosStyle).toBeUndefined();
expect(androidStyle).toBe(iosStyle);
});

it.each(['ios', 'android'])(
'keeps landscape side insets for a bottom formSheet while dropping the top clearance (%s)',
async os => {
rn.os = os;
rn.statusHeight = 48;
safeArea.top = 24;
safeArea.left = 47;
safeArea.right = 59;
const renderer = await mount({
title: 'Voice language',
onDone: () => undefined,
onCancel: () => undefined,
topInset: 'bottom-form-sheet',
});

const wrapper = findSafeAreaWrapper(renderer.root);
expect(wrapper.props.style).toEqual({ paddingLeft: 47, paddingRight: 59 });

renderer.unmount();
}
);

it.each(['ios', 'android'])(
'passes the bottom-form-sheet top-inset mode through PickerSheet (%s)',
async os => {
// The picker shells are bottom formSheets: they sit below the status
// bar, so the shell must not reserve the window's top inset.
rn.os = os;
rn.statusHeight = 48;
safeArea.top = 24;
const renderer = await mountElement(
createElement(
PickerSheet,
{ title: 'Voice language', onDone: () => undefined },
createElement('Text', null, 'Picker content')
)
);

const wrapper = findSafeAreaWrapper(renderer.root);
// The wrapper must be SheetHeader's own inset wrapper, not PickerSheet's
// className-less shell, or this assertion is vacuous.
expect(pressablesByLabel(renderer.root, 'Done')[0]?.parent?.parent).toBe(wrapper);
expect(wrapper.props.style).toBeUndefined();

renderer.unmount();
}
);
});
Loading