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
16 changes: 10 additions & 6 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,13 @@ const config: ExpoConfig = {
owner: 'kilocode',
slug: 'kilo-app',
version: '1.0.10',
// Portrait-only is an accepted, documented product deviation from WCAG 1.3.4
// (Orientation). Landscape layouts and iPad split-view/multitasking are out
// of scope; `ios.requireFullScreen` below enforces that. This is not claimed
// as a WCAG "essential" exception, which requires functionality to
// fundamentally change with orientation.
orientation: 'portrait',
// Rotation is supported on iOS and Android: `default` resolves to portrait +
// both landscapes in UISupportedInterfaceOrientations on iOS and all
// orientations in the Android manifest, satisfying WCAG 1.3.4 (Orientation)
// without claiming an "essential" exception. `ios.requireFullScreen` below
// STAYS true so iPad split-view/multitasking remains out of scope:
// full-screen rotation yes, Split View/Slide Over no.
orientation: 'default',
icon: './assets/images/logo.png',
scheme: 'kiloapp',
userInterfaceStyle: 'automatic',
Expand Down Expand Up @@ -262,6 +263,9 @@ const config: ExpoConfig = {
},
],
'./plugins/withAndroidManifestFix',
// Window background follows the app theme (values-night aware) so the
// rotation surface resize never paints a foreign blank frame.
'./plugins/withAndroidRotationSurface',
'./plugins/withAndroidExpoModuleRepos',
// Declares the app's languages on the widget extension, which expo-widgets
// leaves English-only. This must be registered BEFORE 'expo-widgets':
Expand Down
80 changes: 80 additions & 0 deletions apps/mobile/plugins/withAndroidRotationSurface.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
const {
AndroidConfig,
withAndroidColors,
withAndroidColorsNight,
withAndroidStyles,
} = require('expo/config-plugins');
const { assignColorValue } = AndroidConfig.Colors;

/**
* Pins the Android window background to the app's own theme background while
* rotation is enabled.
*
* With `orientation: 'default'` the activity handles orientation config
* changes itself, and Android resizes the window surface across the rotation.
* Until React paints the first frame in the new orientation, the window shows
* `android:windowBackground` — the AppCompat DayNight default (foreign white
* in light mode, near-black in dark mode), which is exactly the blank frame a
* screen capture taken during the rotation records. Pointing the attribute at
* the same tokens `src/global.css` resolves (`--background`: #FBFAF5 light,
* #0E0E10 dark, via values-night) makes every such gap render the app's own
* screen color in both UI modes instead of a foreign blank.
*
* The splash theme (`Theme.App.SplashScreen`, yellow) is untouched: it only
* governs the launch frame before `postSplashScreenTheme` (AppTheme) applies.
*/

/** Mirrors src/global.css `--background` (light). */
const APP_BACKGROUND_LIGHT = '#FBFAF5';
/** Mirrors src/global.css `--background` (dark, prefers-color-scheme). */
const APP_BACKGROUND_DARK = '#0E0E10';

const COLOR_NAME = 'app_background';
const THEME_NAME = 'AppTheme';
const WINDOW_BACKGROUND_ITEM = 'android:windowBackground';

function setItem(theme, name, value) {
theme.item ??= [];
const existing = theme.item.find(item => item.$?.name === name);
if (existing) {
existing._ = value;
return;
}
theme.item.push({ $: { name }, _: value });
}

function withRotationSurfaceColors(config) {
return withAndroidColors(config, config => {
assignColorValue(config.modResults, {
name: COLOR_NAME,
value: APP_BACKGROUND_LIGHT,
});
return config;
});
}

function withRotationSurfaceColorsNight(config) {
return withAndroidColorsNight(config, config => {
assignColorValue(config.modResults, {
name: COLOR_NAME,
value: APP_BACKGROUND_DARK,
});
return config;
});
}

function withRotationSurfaceStyles(config) {
return withAndroidStyles(config, config => {
const themes = config.modResults.resources.style ?? [];
const appTheme = themes.find(theme => theme.$?.name === THEME_NAME);
if (appTheme) {
setItem(appTheme, WINDOW_BACKGROUND_ITEM, `@color/${COLOR_NAME}`);
}
return config;
});
}

const withAndroidRotationSurface = config =>
withRotationSurfaceStyles(withRotationSurfaceColorsNight(withRotationSurfaceColors(config)));

module.exports = withAndroidRotationSurface;
19 changes: 18 additions & 1 deletion apps/mobile/scripts/assert-expo-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
import { ENV_KEYS } from '../src/lib/env-keys.js';

// Contract values mirrored from app.config.ts (bundle id, package, scheme,
// associated domain, blocked permissions, and Sentry plugin). ENV_KEYS is
// orientation, associated domain, blocked permissions, and Sentry plugin). ENV_KEYS is
// imported live from src/lib/env-keys.js. The script runs the full evaluated
// config, so these must match the resolved build-time output, not the raw
// app.config.ts source.
Expand All @@ -19,6 +19,7 @@ const BLOCKED_PERMISSIONS = [
'android.permission.READ_MEDIA_AUDIO',
];
const SENTRY_PLUGIN = '@sentry/react-native/expo';
const ROTATION_SURFACE_PLUGIN = './plugins/withAndroidRotationSurface';

const mobileDir = join(dirname(fileURLToPath(import.meta.url)), '..');

Expand Down Expand Up @@ -57,6 +58,15 @@ check(
check(config.android?.package === ANDROID_PACKAGE, `android.package must be "${ANDROID_PACKAGE}"`);
check(config.scheme === SCHEME, `scheme must be "${SCHEME}"`);

// Rotation contract: all device orientations enabled (portrait + both
// landscapes on iOS, all orientations on Android), while iPad multitasking
// stays off — requireFullScreen keeps Split View/Slide Over out of scope.
check(config.orientation === 'default', `orientation must be "default"`);
check(
config.ios?.requireFullScreen === true,
'ios.requireFullScreen must be true (iPad Split View/Slide Over stays out of scope)'
);

const associatedDomains = config.ios?.associatedDomains ?? [];
check(
associatedDomains.includes(ASSOCIATED_DOMAIN),
Expand All @@ -76,6 +86,13 @@ const pluginNames = (config.plugins ?? []).map(plugin =>
Array.isArray(plugin) ? plugin[0] : plugin
);
check(pluginNames.includes(SENTRY_PLUGIN), `plugins must include "${SENTRY_PLUGIN}"`);
// The rotation surface plugin pins the Android window background to the theme
// background; without it a rotation paints the AppCompat DayNight default
// until React's first frame lands in the new orientation.
check(
pluginNames.includes(ROTATION_SURFACE_PLUGIN),
`plugins must include "${ROTATION_SURFACE_PLUGIN}"`
);

const extra = config.extra ?? {};
for (const key of Object.keys(ENV_KEYS)) {
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/app/(app)/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from '@/lib/session-attention';
import {
getEffectiveTabBarHeight,
getTabBarHorizontalInset,
getTabBarIconSize,
shouldHideTabBar,
shouldShowTabLabel,
Expand Down Expand Up @@ -69,7 +70,7 @@ export default function TabsLayout() {
const pathname = usePathname();
const segments = useSegments();
const colors = useThemeColors();
const { bottom } = useSafeAreaInsets();
const { bottom, left, right } = useSafeAreaInsets();
const { fontScale } = useWindowDimensions();
const hideTabs = shouldHideTabBar(pathname);
const showTabLabel = shouldShowTabLabel(fontScale);
Expand All @@ -78,6 +79,7 @@ export default function TabsLayout() {
platform: Platform.OS,
fontScale,
});
const tabBarHorizontalInset = getTabBarHorizontalInset({ left, right });
const tabIconSize = getTabBarIconSize(fontScale);
const showKiloClawTab = useKiloClawTabVisible();
const showQuickChatTab = useFeatureFlag(FEATURE_FLAG_QUICK_CHAT, false);
Expand Down Expand Up @@ -136,6 +138,7 @@ export default function TabsLayout() {
elevation: 0,
height: tabBarHeight,
position: 'absolute',
...tabBarHorizontalInset,
},
tabBarShowLabel: showTabLabel,
}}
Expand Down
10 changes: 8 additions & 2 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -975,9 +975,12 @@ function RootLayoutNav({
// from touch, but not from screen readers. Leave both accessibility
// trees while hidden (iOS, then Android). The held error surface
// forces the same presentation: it owns the screen above the wrapper.
// `bg-background` keeps the root surface opaque: while a rotation
// relayout runs, frames before React's first commit must show the
// app's own background, never the window's foreign default.
accessibilityElementsHidden={hidden || showRestoreError}
importantForAccessibility={hidden || showRestoreError ? 'no-hide-descendants' : 'auto'}
className={`flex-1 ${hidden || showRestoreError ? 'opacity-0' : 'opacity-100'}`}
className={`flex-1 bg-background ${hidden || showRestoreError ? 'opacity-0' : 'opacity-100'}`}
pointerEvents={hidden || showRestoreError ? 'none' : 'auto'}
>
<Slot />
Expand Down Expand Up @@ -1009,7 +1012,10 @@ function AppContentReveal({ children }: Readonly<{ children: React.ReactNode }>)
transform: [{ scale: splashContentScale.value }],
}));
return (
<Animated.View className="flex-1" style={style}>
// bg-background keeps the scaled wrapper opaque over the window: the
// overscan frame and every relayout gap behind it render the app's own
// background, never the platform default.
<Animated.View className="flex-1 bg-background" style={style}>
{children}
</Animated.View>
);
Expand Down
63 changes: 59 additions & 4 deletions apps/mobile/src/components/agents/chat-composer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { CLOUD_AGENT_PROMPT_MAX_LENGTH } from '@kilocode/cloud-agent-sdk/limits'
import { type ChatComposer } from './chat-composer';

const layoutDirection = vi.hoisted(() => ({ isRTL: false }));
const safeAreaInsets = vi.hoisted(() => ({ bottom: 0, left: 0, right: 0, top: 0 }));
const TEXT_DIRECTIONS = [
{ direction: 'LTR', isRTL: false, style: undefined },
{ direction: 'RTL', isRTL: true, style: [{ writingDirection: 'rtl' }, undefined] },
Expand Down Expand Up @@ -95,7 +96,7 @@ vi.mock('react-native', () => ({
}));

vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 }),
useSafeAreaInsets: () => safeAreaInsets,
}));

vi.mock('react-native-gesture-handler', () => ({
Expand Down Expand Up @@ -223,9 +224,11 @@ vi.mock('@/components/agents/chat-composer-input-state', () => ({
},
}));

vi.mock('@/components/ui/blur-bar', () => ({
BlurBar: () => null,
}));
// The composer's root element; located by identity, never by a __testMarker
// (findInputRowProps treats any marked function as the input row).
const MockBlurBar = () => null;

vi.mock('@/components/ui/blur-bar', () => ({ BlurBar: MockBlurBar }));

vi.mock('@/components/voice-input-control', () => ({
VoiceInputStatus: () => null,
Expand Down Expand Up @@ -378,6 +381,27 @@ function findStripProps(node: Node): Record<string, unknown> | null {
return null;
}

// The composer pads the content inside its root BlurBar with the landscape
// sensor side insets. The container is the only View in the returned tree
// carrying a style prop, so it is located by that style shape.
function findComposerInsetContainer(render: React.ReactElement): {
type: unknown;
props: Record<string, unknown>;
} {
const container = findNode(
render,
(type, props) =>
type === 'View' &&
typeof props.style === 'object' &&
props.style !== null &&
'paddingLeft' in props.style
);
if (container === null) {
throw new Error('composer side-inset container not found in the BlurBar content');
}
return container;
}

function requireInputRowOnSubmit(render: React.ReactElement): () => void {
const rowProps = findInputRowProps(render);
const onSubmit = rowProps?.onSubmit as (() => void) | undefined;
Expand Down Expand Up @@ -452,6 +476,10 @@ beforeEach(() => {
returnSendsPref.returnSendsMessage = false;
reducedMotionOn.value = false;
layoutDirection.isRTL = false;
safeAreaInsets.bottom = 0;
safeAreaInsets.left = 0;
safeAreaInsets.right = 0;
safeAreaInsets.top = 0;
});

// The restore contract has one axis: whether the host resolved a draft. Both
Expand Down Expand Up @@ -657,3 +685,30 @@ describe('ChatComposer attachment strip wiring', () => {
expect(stripProps.onReorder).toBe(uploadReorderAttachmentsMock);
});
});

describe('ChatComposer landscape side insets', () => {
it('keeps portrait geometry with zero side padding', async () => {
safeAreaInsets.left = 0;
safeAreaInsets.right = 0;
const render = await mount(makeProps({}));

const container = findComposerInsetContainer(render);
expect(container.props.style).toEqual({ paddingLeft: 0, paddingRight: 0 });
// The unpadded container still hosts the whole composer content.
expect(findNode(container, type => type === MockInputRow)).not.toBeNull();
});

it('pads the composer content by the landscape sensor insets', async () => {
// iPhone sensor notch in landscape: a wider left inset than right.
safeAreaInsets.left = 59;
safeAreaInsets.right = 47;
const render = await mount(makeProps({}));

const container = findComposerInsetContainer(render);
expect(container.props.style).toEqual({ paddingLeft: 59, paddingRight: 47 });
// Toolbar, input row, and send control all clear the sensor area because
// they live inside the padded container.
expect(findNode(container, type => type === MockChatToolbar)).not.toBeNull();
expect(findNode(container, type => type === MockInputRow)).not.toBeNull();
});
});
Loading