diff --git a/apps/mobile/src/components/agents/chat-composer-input-height.test.ts b/apps/mobile/src/components/agents/chat-composer-input-height.test.ts
index 4a66573a69..f127fbf0af 100644
--- a/apps/mobile/src/components/agents/chat-composer-input-height.test.ts
+++ b/apps/mobile/src/components/agents/chat-composer-input-height.test.ts
@@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest';
import {
COMPOSER_CHROME_HEIGHT,
+ COMPOSER_INPUT_MAX_HEIGHT,
COMPOSER_INPUT_PADDING_HORIZONTAL,
NEW_SESSION_PROMPT_CHROME_HEIGHT,
+ NEW_SESSION_PROMPT_INPUT_MAX_HEIGHT,
resolveComposerMaxHeight,
resolveComposerTextContentWidth,
shouldEnableComposerInputScroll,
@@ -21,6 +23,7 @@ const MAX_HEIGHT_ARGS = {
sessionHeaderHeight: 92,
composerChromeHeight: 120,
minHeight: MIN,
+ absoluteMaxHeight: 1000,
} as const;
describe('shouldEnableComposerInputScroll', () => {
@@ -58,6 +61,19 @@ describe('resolveComposerMaxHeight', () => {
expect(resolveComposerMaxHeight(MAX_HEIGHT_ARGS)).toBe(374);
});
+ it('caps the input below the remaining space', () => {
+ // A tall window with no keyboard leaves 1000 - 44 - 34 - 92 - 120 = 710,
+ // but the absolute cap bounds it so the input cannot fill the screen.
+ expect(
+ resolveComposerMaxHeight({ ...MAX_HEIGHT_ARGS, keyboardHeight: 0, absoluteMaxHeight: MAX })
+ ).toBe(MAX);
+ });
+
+ it('exposes the chat and new-session caps', () => {
+ expect(COMPOSER_INPUT_MAX_HEIGHT).toBe(124);
+ expect(NEW_SESSION_PROMPT_INPUT_MAX_HEIGHT).toBe(160);
+ });
+
it('floors at minHeight when the remaining space is smaller', () => {
expect(
resolveComposerMaxHeight({
diff --git a/apps/mobile/src/components/agents/chat-composer-input-height.ts b/apps/mobile/src/components/agents/chat-composer-input-height.ts
index 243dc7b13f..a6886fa39c 100644
--- a/apps/mobile/src/components/agents/chat-composer-input-height.ts
+++ b/apps/mobile/src/components/agents/chat-composer-input-height.ts
@@ -60,13 +60,30 @@ export function shouldEnableComposerInputScroll(height: number, maxHeight: numbe
}
/**
- * Remaining-space cap for the composer input, replacing the fixed 124/160pt
- * caps. The input may grow only into the space left after the keyboard, the
- * safe areas, the session header, and every other piece of composer chrome
- * (attachment strip, send/stop, mic, newline control, starters, counter) are
- * subtracted from the window height. The result is floored at `minHeight` so
- * a single-line input is always readable, and a degenerate window (keyboard +
- * chrome exceeding the window) can never return a negative height.
+ * Hard cap for the agent chat composer input, in unscaled points.
+ *
+ * The remaining-space cap alone lets the input fill a tall window (and the
+ * whole space above the keyboard on a tablet), which pushes the transcript off
+ * screen. The input scrolls past this height instead of growing further.
+ */
+export const COMPOSER_INPUT_MAX_HEIGHT = 124;
+
+/**
+ * Hard cap for the new-session prompt input, in unscaled points. Larger than
+ * the chat composer cap: the prompt form has no transcript to protect and the
+ * first task can be several lines.
+ */
+export const NEW_SESSION_PROMPT_INPUT_MAX_HEIGHT = 160;
+
+/**
+ * Remaining-space cap for the composer input, bounded by an absolute cap. The
+ * input may grow only into the space left after the keyboard, the safe areas,
+ * the session header, and every other piece of composer chrome (attachment
+ * strip, send/stop, mic, newline control, starters, counter) are subtracted
+ * from the window height, and never past `absoluteMaxHeight`. The result is
+ * floored at `minHeight` so a single-line input is always readable, and a
+ * degenerate window (keyboard + chrome exceeding the window) can never return
+ * a negative height.
*/
export function resolveComposerMaxHeight({
windowHeight,
@@ -76,6 +93,7 @@ export function resolveComposerMaxHeight({
sessionHeaderHeight,
composerChromeHeight,
minHeight,
+ absoluteMaxHeight,
}: {
windowHeight: number;
safeAreaInsetTop: number;
@@ -84,6 +102,7 @@ export function resolveComposerMaxHeight({
sessionHeaderHeight: number;
composerChromeHeight: number;
minHeight: number;
+ absoluteMaxHeight: number;
}): number {
const remaining =
windowHeight -
@@ -92,5 +111,5 @@ export function resolveComposerMaxHeight({
keyboardHeight -
sessionHeaderHeight -
composerChromeHeight;
- return Math.max(minHeight, Math.floor(remaining));
+ return Math.max(minHeight, Math.min(Math.floor(remaining), absoluteMaxHeight));
}
diff --git a/apps/mobile/src/components/agents/chat-composer.tsx b/apps/mobile/src/components/agents/chat-composer.tsx
index b167d04408..7c2c5ce586 100644
--- a/apps/mobile/src/components/agents/chat-composer.tsx
+++ b/apps/mobile/src/components/agents/chat-composer.tsx
@@ -58,6 +58,7 @@ import {
} from '@/components/agents/composer-paste-text';
import {
COMPOSER_CHROME_HEIGHT,
+ COMPOSER_INPUT_MAX_HEIGHT,
COMPOSER_INPUT_PADDING_HORIZONTAL,
resolveComposerMaxHeight,
resolveComposerTextContentWidth,
@@ -373,6 +374,7 @@ export function ChatComposer({
sessionHeaderHeight: SESSION_HEADER_HEIGHT * fontScale,
composerChromeHeight: COMPOSER_CHROME_HEIGHT * fontScale,
minHeight: inputMinHeight,
+ absoluteMaxHeight: COMPOSER_INPUT_MAX_HEIGHT * fontScale,
});
// Track the keyboard's reported height so the remaining-space cap follows it.
diff --git a/apps/mobile/src/components/agents/model-selector.tsx b/apps/mobile/src/components/agents/model-selector.tsx
index 28d8af6086..89c6e8cb10 100644
--- a/apps/mobile/src/components/agents/model-selector.tsx
+++ b/apps/mobile/src/components/agents/model-selector.tsx
@@ -171,15 +171,12 @@ export function ModelSelector({
accessibilityLabel={accessibilityLabel}
accessibilityState={{ disabled: effectivelyDisabled }}
className={cn(
- 'max-w-[240px] min-w-0 shrink flex-row items-center gap-1.5 rounded-full bg-secondary px-3 py-1.5 active:opacity-70',
+ 'min-w-0 shrink flex-row items-center gap-1.5 rounded-full bg-secondary px-3 py-1.5 active:opacity-70',
effectivelyDisabled && 'opacity-50'
)}
>
-
+
{label}
{byok ? (
diff --git a/apps/mobile/src/components/agents/new-session-prompt.tsx b/apps/mobile/src/components/agents/new-session-prompt.tsx
index 96101af4d3..8291aeef8b 100644
--- a/apps/mobile/src/components/agents/new-session-prompt.tsx
+++ b/apps/mobile/src/components/agents/new-session-prompt.tsx
@@ -30,6 +30,7 @@ import { ChatToolbar } from '@/components/agents/chat-toolbar';
import { useTextHeight } from '@/components/agents/use-text-height';
import {
NEW_SESSION_PROMPT_CHROME_HEIGHT,
+ NEW_SESSION_PROMPT_INPUT_MAX_HEIGHT,
resolveComposerMaxHeight,
SESSION_HEADER_HEIGHT,
} from '@/components/agents/chat-composer-input-height';
@@ -157,6 +158,7 @@ export function NewSessionPrompt({
sessionHeaderHeight: SESSION_HEADER_HEIGHT * fontScale,
composerChromeHeight: NEW_SESSION_PROMPT_CHROME_HEIGHT * fontScale,
minHeight: promptMinHeight,
+ absoluteMaxHeight: NEW_SESSION_PROMPT_INPUT_MAX_HEIGHT * fontScale,
});
// Track the keyboard's reported height so the remaining-space cap follows it.
diff --git a/apps/mobile/src/components/screen-header.mounted.test.tsx b/apps/mobile/src/components/screen-header.mounted.test.tsx
index 9c9db3802e..6b9fe37845 100644
--- a/apps/mobile/src/components/screen-header.mounted.test.tsx
+++ b/apps/mobile/src/components/screen-header.mounted.test.tsx
@@ -358,8 +358,9 @@ describe('ScreenHeader mounted', () => {
}
if (props.modal || props.centerTitle) {
expect(title.props.className).toContain('text-center');
- expect(title.parent?.parent).not.toBe(back.parent);
- expect(title.parent?.parent?.parent).toBe(back.parent?.parent?.parent);
+ // The centered title shares one row with the leading control, so the
+ // control lines up with the title instead of drawing on its own row.
+ expect(title.parent?.parent?.parent).toBe(back.parent);
}
}
});
diff --git a/apps/mobile/src/components/screen-header.tsx b/apps/mobile/src/components/screen-header.tsx
index b97b5b6f3a..3129b008af 100644
--- a/apps/mobile/src/components/screen-header.tsx
+++ b/apps/mobile/src/components/screen-header.tsx
@@ -177,46 +177,67 @@ export function ScreenHeader({
{context}
);
+ // A centered title shares its row with the leading and trailing controls.
+ // A spacer opposite the back control keeps the title centered on the full
+ // width without placing either control out of flow.
const separateHeading = centerTitle && (Boolean(title) || Boolean(eyebrow));
+ const backControl = canGoBack ? (
+ {
+ if (onBack) {
+ onBack();
+ } else if (backFallback !== undefined && !router.canGoBack()) {
+ router.replace(backFallback);
+ } else {
+ router.back();
+ }
+ }}
+ accessibilityRole="button"
+ accessibilityLabel={resolvedBackIcon === 'close' ? t('common.close') : t('common.goBack')}
+ className={cn(
+ 'h-11 w-11 shrink-0 items-center justify-center active:opacity-70',
+ !separateHeading && (I18nManager.isRTL ? '-mr-4' : '-ml-4')
+ )}
+ >
+ {resolvedBackIcon === 'close' ? (
+
+ ) : (
+
+ )}
+
+ ) : null;
+ const centeredControls =
+ separateHeading && backControl && !headerRight ? (
+
+ ) : null;
+
return (
- {separateHeading && {heading}}
-
-
- {canGoBack && (
- {
- if (onBack) {
- onBack();
- } else if (backFallback !== undefined && !router.canGoBack()) {
- router.replace(backFallback);
- } else {
- router.back();
- }
- }}
- accessibilityRole="button"
- accessibilityLabel={
- resolvedBackIcon === 'close' ? t('common.close') : t('common.goBack')
- }
- className={`${I18nManager.isRTL ? '-mr-4' : '-ml-4'} h-11 w-11 shrink-0 items-center justify-center active:opacity-70`}
- >
- {resolvedBackIcon === 'close' ? (
-
- ) : (
-
- )}
-
+ {separateHeading ? (
+
+ {backControl}
+ {heading}
+ {headerRight ? (
+ {headerRight}
+ ) : (
+ centeredControls
)}
- {!separateHeading && heading}
- {headerRight ? (
-
- {headerRight}
+ ) : (
+
+
+ {backControl}
+ {heading}
- ) : null}
-
+ {headerRight ? (
+
+ {headerRight}
+
+ ) : null}
+
+ )}
);
diff --git a/apps/mobile/src/glanceable-ios/layout-copy.ts b/apps/mobile/src/glanceable-ios/layout-copy.ts
index 7486aea9b8..f78d2058c0 100644
--- a/apps/mobile/src/glanceable-ios/layout-copy.ts
+++ b/apps/mobile/src/glanceable-ios/layout-copy.ts
@@ -57,11 +57,34 @@ export function glanceableLayoutCopy() {
running: i18n.t('common.working'),
idle: i18n.t('common.idle'),
openAgents: i18n.t('glanceable.openAgents'),
- locale: i18n.language.replace('-', '_'),
+ locale: resolveGlanceableLocale(i18n.language),
digits: glanceableDigits(),
};
}
+/**
+ * App languages whose catalog script differs from the script a bare language
+ * tag makes SwiftUI pick. Serbian ships a Latin catalog here, but a bare `sr`
+ * tag formats the relative wait in Cyrillic ("мин"), beside Latin labels. The
+ * override names the script; `resolveGlanceableLocale` normalizes it.
+ */
+function glanceableLocaleScriptOverride(language: string): string | undefined {
+ if (language === 'sr') {
+ return 'sr-Latn';
+ }
+ return undefined;
+}
+
+/**
+ * The SwiftUI locale tag for an app language, in the underscore form the
+ * `@expo/ui` locale modifier accepts (see the note on `glanceableLayoutCopy`).
+ * Languages that write a non-default script for their tag are mapped so the
+ * relative wait uses the same script as the baked labels.
+ */
+export function resolveGlanceableLocale(language: string): string {
+ return (glanceableLocaleScriptOverride(language) ?? language).replace('-', '_');
+}
+
/**
* Resolve the copy placeholder inside a stringified `'widget'` layout.
*
diff --git a/apps/mobile/src/glanceable-ios/layout-locale-script.test.ts b/apps/mobile/src/glanceable-ios/layout-locale-script.test.ts
new file mode 100644
index 0000000000..7566ee11d9
--- /dev/null
+++ b/apps/mobile/src/glanceable-ios/layout-locale-script.test.ts
@@ -0,0 +1,174 @@
+/* eslint-disable eslint-plugin-import/no-nodejs-modules, eslint-plugin-unicorn/prefer-module, anti-slop/no-runtime-typeof -- this test reads the locale catalogs from disk, and JSON.parse returns unknown, which needs a runtime check */
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+import { describe, expect, it } from 'vitest';
+
+import { SUPPORTED_LANGUAGES } from '@/i18n/languages';
+
+import { resolveGlanceableLocale } from './layout-copy';
+
+const LOCALES_DIR = join(__dirname, '..', 'i18n', 'locales');
+
+/**
+ * The scripts a supported catalog can be written in. `resolveGlanceableLocale`
+ * only has to correct a language whose catalog script differs from the script
+ * a bare tag makes SwiftUI pick. A Latin catalog is inferred when no other
+ * script clears the noise floor, because every catalog carries Latin brand
+ * names and placeholders.
+ */
+const CATALOG_SCRIPTS = [
+ 'Latin',
+ 'Cyrillic',
+ 'Greek',
+ 'Arabic',
+ 'Hebrew',
+ 'Han',
+ 'Hiragana',
+ 'Katakana',
+ 'Hangul',
+ 'Devanagari',
+ 'Bengali',
+ 'Gurmukhi',
+ 'Gujarati',
+ 'Oriya',
+ 'Tamil',
+ 'Telugu',
+ 'Kannada',
+ 'Malayalam',
+ 'Sinhala',
+ 'Thai',
+ 'Lao',
+ 'Myanmar',
+ 'Khmer',
+ 'Georgian',
+ 'Armenian',
+ 'Ethiopic',
+ 'Tibetan',
+] as const;
+
+/** The CLDR script subtag for a catalog script, when they differ in spelling. */
+const CLDR_SCRIPT_SUBTAG = {
+ Latin: 'Latn',
+ Cyrillic: 'Cyrl',
+ Greek: 'Grek',
+ Arabic: 'Arab',
+ Hebrew: 'Hebr',
+ Han: 'Hani',
+ Hiragana: 'Jpan',
+ Katakana: 'Jpan',
+ Hangul: 'Kore',
+ Devanagari: 'Deva',
+ Bengali: 'Beng',
+ Gurmukhi: 'Guru',
+ Gujarati: 'Gujr',
+ Oriya: 'Orya',
+ Tamil: 'Taml',
+ Telugu: 'Telu',
+ Kannada: 'Knda',
+ Malayalam: 'Mlym',
+ Sinhala: 'Sinh',
+ Thai: 'Thai',
+ Lao: 'Laoo',
+ Myanmar: 'Mymr',
+ Khmer: 'Khmr',
+ Georgian: 'Geor',
+ Armenian: 'Armn',
+ Ethiopic: 'Ethi',
+ Tibetan: 'Tibt',
+} satisfies Record<(typeof CATALOG_SCRIPTS)[number], string>;
+
+const SCRIPT_MATCHERS = CATALOG_SCRIPTS.map(script => {
+ try {
+ return { script, pattern: new RegExp(`\\p{Script=${script}}`, 'u') };
+ } catch {
+ return null;
+ }
+}).filter(entry => entry !== null);
+
+/** The fewest copies of a script before it counts as the catalog's script. */
+const SCRIPT_NOISE_FLOOR = 20;
+
+function collectStrings(node: unknown, out: string[]): void {
+ if (typeof node === 'string') {
+ out.push(node);
+ return;
+ }
+ if (Array.isArray(node)) {
+ for (const item of node) {
+ collectStrings(item, out);
+ }
+ return;
+ }
+ if (node !== null && typeof node === 'object') {
+ for (const value of Object.values(node)) {
+ collectStrings(value, out);
+ }
+ }
+}
+
+/** The script a catalog is written in, Latin unless another clears the floor. */
+function catalogScript(tag: string): (typeof CATALOG_SCRIPTS)[number] {
+ const parsed: unknown = JSON.parse(readFileSync(join(LOCALES_DIR, `${tag}.json`), 'utf8'));
+ const strings: string[] = [];
+ collectStrings(parsed, strings);
+ const text = strings.join('\n').replaceAll(/\{\{[^}]*\}\}/g, ' ');
+
+ const counts = new Map();
+ for (const character of text) {
+ for (const { script, pattern } of SCRIPT_MATCHERS) {
+ if (pattern.test(character)) {
+ counts.set(script, (counts.get(script) ?? 0) + 1);
+ break;
+ }
+ }
+ }
+
+ let dominant: (typeof CATALOG_SCRIPTS)[number] = 'Latin';
+ let dominantCount = SCRIPT_NOISE_FLOOR - 1;
+ for (const [script, count] of counts) {
+ if (script !== 'Latin' && count > dominantCount && isCatalogScript(script)) {
+ dominant = script;
+ dominantCount = count;
+ }
+ }
+ return dominant;
+}
+
+function isCatalogScript(script: string): script is (typeof CATALOG_SCRIPTS)[number] {
+ return (CATALOG_SCRIPTS as readonly string[]).includes(script);
+}
+
+function cldrScriptForTag(tag: string): string {
+ return new Intl.Locale(tag).maximize().script ?? '';
+}
+
+const SCRIPTLESS_LANGUAGES = SUPPORTED_LANGUAGES.filter(tag => !tag.includes('-'));
+
+/**
+ * The only supported language whose catalog script differs from the script a
+ * bare tag makes SwiftUI pick. Verified against every catalog: Serbian ships a
+ * Latin catalog while its tag defaults to Cyrillic. A new language that adds
+ * this divergence fails the assertion and must be added to the map.
+ */
+const KNOWN_SCRIPT_DIVERGENCE = ['sr'];
+
+describe('glanceable locale script', () => {
+ it('names a script only for catalogs whose script differs from the tag default', () => {
+ const divergent = SCRIPTLESS_LANGUAGES.filter(
+ tag => CLDR_SCRIPT_SUBTAG[catalogScript(tag)] !== cldrScriptForTag(tag)
+ );
+ expect(divergent).toEqual(KNOWN_SCRIPT_DIVERGENCE);
+ });
+
+ it('keeps an explicit script subtag on a language that carries one', () => {
+ for (const tag of SUPPORTED_LANGUAGES.filter(candidate => candidate.includes('-'))) {
+ expect(resolveGlanceableLocale(tag)).toBe(tag.replace('-', '_'));
+ }
+ });
+
+ it('resolves Serbian to the Latin script and English to itself', () => {
+ expect(resolveGlanceableLocale('sr')).toBe('sr_Latn');
+ expect(resolveGlanceableLocale('en')).toBe('en');
+ });
+});