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'); + }); +});