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 @@ -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,
Expand All @@ -21,6 +23,7 @@ const MAX_HEIGHT_ARGS = {
sessionHeaderHeight: 92,
composerChromeHeight: 120,
minHeight: MIN,
absoluteMaxHeight: 1000,
} as const;

describe('shouldEnableComposerInputScroll', () => {
Expand Down Expand Up @@ -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({
Expand Down
35 changes: 27 additions & 8 deletions apps/mobile/src/components/agents/chat-composer-input-height.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -76,6 +93,7 @@ export function resolveComposerMaxHeight({
sessionHeaderHeight,
composerChromeHeight,
minHeight,
absoluteMaxHeight,
}: {
windowHeight: number;
safeAreaInsetTop: number;
Expand All @@ -84,6 +102,7 @@ export function resolveComposerMaxHeight({
sessionHeaderHeight: number;
composerChromeHeight: number;
minHeight: number;
absoluteMaxHeight: number;
}): number {
const remaining =
windowHeight -
Expand All @@ -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));
}
2 changes: 2 additions & 0 deletions apps/mobile/src/components/agents/chat-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 2 additions & 5 deletions apps/mobile/src/components/agents/model-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)}
>
<View className="min-w-0 shrink flex-row items-center gap-1.5">
<Text
className="max-w-[170px] shrink text-sm font-medium text-foreground"
numberOfLines={1}
>
<Text className="shrink text-sm font-medium text-foreground" numberOfLines={1}>
{label}
</Text>
{byok ? (
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/components/agents/new-session-prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions apps/mobile/src/components/screen-header.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
});
Expand Down
85 changes: 53 additions & 32 deletions apps/mobile/src/components/screen-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,46 +177,67 @@ export function ScreenHeader({
{context}
</View>
);
// 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 ? (
<Pressable
onPress={() => {
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' ? (
<ChevronDown size={24} color={colors.foreground} />
) : (
<DirectionalChevronLeft size={24} color={colors.foreground} />
)}
</Pressable>
) : null;
const centeredControls =
separateHeading && backControl && !headerRight ? (
<View className="h-11 w-11 shrink-0" accessibilityElementsHidden pointerEvents="none" />
) : null;

return (
<View className={cn('bg-background px-4 pb-3', className)} style={safeAreaStyle}>
<View style={sideInsetStyle}>
{separateHeading && <View className="min-h-11 flex-row items-center">{heading}</View>}
<View className="flex-row items-center">
<View className="min-w-0 flex-1 flex-row items-center gap-1">
{canGoBack && (
<Pressable
onPress={() => {
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' ? (
<ChevronDown size={24} color={colors.foreground} />
) : (
<DirectionalChevronLeft size={24} color={colors.foreground} />
)}
</Pressable>
{separateHeading ? (
<View className="min-h-11 flex-row items-center">
{backControl}
<View className="min-w-0 flex-1 flex-row items-center justify-center">{heading}</View>
{headerRight ? (
<View className="ms-3 max-w-[50%] min-w-0 shrink">{headerRight}</View>
) : (
centeredControls
)}
{!separateHeading && heading}
</View>
{headerRight ? (
<View className={`${I18nManager.isRTL ? 'mr-3' : 'ml-3'} min-w-0 max-w-[50%] shrink`}>
{headerRight}
) : (
<View className="flex-row items-center">
<View className="min-w-0 flex-1 flex-row items-center gap-1">
{backControl}
{heading}
</View>
) : null}
</View>
{headerRight ? (
<View className={`${I18nManager.isRTL ? 'mr-3' : 'ml-3'} min-w-0 max-w-[50%] shrink`}>
{headerRight}
</View>
) : null}
</View>
)}
</View>
</View>
);
Expand Down
25 changes: 24 additions & 1 deletion apps/mobile/src/glanceable-ios/layout-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Loading