Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
37c9a42
feat(mobile): adapt agent composers for keyboard, height, and return-…
iscekic Aug 27, 2026
b5ffc3d
feat(mobile): follow the keyboard and add return-to-send preference
iscekic Aug 27, 2026
41a6f73
test(mobile): cover agent composer height, return, and a11y
iscekic Aug 27, 2026
83f4195
chore(format): wrap composer starter condition
iscekic Aug 27, 2026
47a9641
test(mobile): mock composer deps in attachment-send suite
iscekic Aug 27, 2026
802c231
Merge branch 'agent-composers-c07a-s5' into agent-composers-c07a-s6
iscekic Aug 27, 2026
89161dc
Merge branch 'agent-composers-c07a-s5' into agent-composers-c07a-s6
iscekic Aug 27, 2026
21b871d
Merge branch 'agent-composers-c07a-s5' into agent-composers-c07a-s6
iscekic Aug 27, 2026
63c3e60
Merge branch 'agent-composers-c07a-s5' into agent-composers-c07a-s6
iscekic Aug 27, 2026
305c0af
fix(mobile): surface upload wait and budget starter row
iscekic Aug 27, 2026
2c8b811
Merge branch 'agent-composers-c07a-s5' into agent-composers-c07a-s6
iscekic Aug 27, 2026
54b8d16
fix(mobile): budget two starter rows in composer chrome
iscekic Aug 27, 2026
b88d498
test(mobile): correct starter-row comment in chrome budget test
iscekic Aug 27, 2026
c3187ea
fix(mobile): add a11y status and counter labels to composer
iscekic Aug 27, 2026
a24f1e1
fix(mobile): replace composer chips when restoring a queued message
iscekic Aug 27, 2026
0563cc1
refactor(mobile): inline cancel-queued restore decision
iscekic Aug 28, 2026
4592389
Merge branch 'agent-composers-c07a-s5' into agent-composers-c07a-s6
iscekic Aug 28, 2026
25329aa
chore(mobile): remove unused return-sends preference reset
iscekic Aug 28, 2026
70f55de
Merge branch 'agent-composers-c07a-s5' into agent-composers-c07a-s6
iscekic Aug 28, 2026
c1a7dc9
chore(mobile): merge prompt controls into composer chrome
iscekic Aug 28, 2026
64ec312
Merge branch 'agent-composers-c07a-s5' into agent-composers-c07a-s6
iscekic Aug 28, 2026
2eff6d7
Merge branch 'agent-composers-c07a-s5' into agent-composers-c07a-s6
iscekic Aug 28, 2026
9fe7d00
Merge branch 'agent-composers-c07a-s5' into agent-composers-c07a-s6
iscekic Aug 28, 2026
74a9fbb
Merge branch 'agent-composers-c07a-s5' into agent-composers-c07a-s6
iscekic Aug 28, 2026
81d77d9
feat(i18n): translate composer controls and preferences
iscekic Aug 28, 2026
52c3387
fix(mobile): use directional text in agent composers
iscekic Aug 28, 2026
c230638
fix(mobile): shrink starter chips and gate the character counter
iscekic Aug 28, 2026
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 @@ -60,13 +60,21 @@ vi.mock('react', async () => {
});

// ── react-native and native bridges ────────────────────────────────────────
vi.mock('@rn-primitives/slot', () => ({ Text: 'SlotText' }));

vi.mock('react-native', () => ({
Alert: { alert: vi.fn() },
AppState: {
addEventListener: () => ({ remove: vi.fn() }),
},
Keyboard: { dismiss: vi.fn() },
Keyboard: {
addListener: vi.fn(() => ({ remove: vi.fn() })),
dismiss: vi.fn(),
},
Platform: { OS: 'ios' },
Pressable: 'Pressable',
Text: 'Text',
useWindowDimensions: () => ({ fontScale: 1, height: 800, scale: 1, width: 400 }),
View: 'View',
}));

Expand Down Expand Up @@ -103,6 +111,11 @@ vi.mock('react-native-reanimated', () => ({
default: { View: 'Animated.View' },
FadeIn: { duration: vi.fn(() => ({})) },
FadeOut: { duration: vi.fn(() => ({})) },
useReducedMotion: () => false,
}));

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

vi.mock('expo-haptics', () => ({
Expand Down Expand Up @@ -186,6 +199,14 @@ vi.mock('@/lib/hooks/use-current-user-id', () => ({
useCurrentUserId: () => ({ userId: 'u1' }),
}));

vi.mock('@/lib/hooks/use-return-sends-message-preference', () => ({
useReturnSendsMessagePreference: () => ({
returnSendsMessage: false,
hasLoaded: true,
setReturnSendsMessage: vi.fn(),
}),
}));

vi.mock('@/lib/agent-attachments/use-agent-attachment-upload', () => ({
useAgentAttachmentUpload: () => ({
attachments: uploadState.attachments,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
import { describe, expect, it } from 'vitest';

import {
COMPOSER_CHROME_HEIGHT,
COMPOSER_INPUT_PADDING_HORIZONTAL,
NEW_SESSION_PROMPT_CHROME_HEIGHT,
resolveComposerMaxHeight,
resolveComposerTextContentWidth,
shouldEnableComposerInputScroll,
STARTER_ROW_HEIGHT,
} from './chat-composer-input-height';

const MIN = 44;
const MAX = 124;

const MAX_HEIGHT_ARGS = {
windowHeight: 1000,
safeAreaInsetTop: 44,
safeAreaInsetBottom: 34,
keyboardHeight: 336,
sessionHeaderHeight: 92,
composerChromeHeight: 120,
minHeight: MIN,
} as const;

describe('shouldEnableComposerInputScroll', () => {
it('is true at or above max and false below', () => {
expect(shouldEnableComposerInputScroll(MAX, MAX)).toBe(true);
Expand All @@ -29,3 +43,38 @@ describe('resolveComposerTextContentWidth', () => {
);
});
});

describe('composer chrome budgets', () => {
it('reserve the starter-row height inside both chrome budgets', () => {
// 120 (composer chrome) + STARTER_ROW_HEIGHT = 176, and 176 + STARTER_ROW_HEIGHT = 232.
expect(COMPOSER_CHROME_HEIGHT - STARTER_ROW_HEIGHT).toBe(120);
expect(NEW_SESSION_PROMPT_CHROME_HEIGHT - STARTER_ROW_HEIGHT).toBe(176);
});
});

describe('resolveComposerMaxHeight', () => {
it('subtracts safe areas, keyboard, header, and chrome from the window height', () => {
// 1000 - 44 - 34 - 336 - 92 - 120 = 374
expect(resolveComposerMaxHeight(MAX_HEIGHT_ARGS)).toBe(374);
});

it('floors at minHeight when the remaining space is smaller', () => {
expect(
resolveComposerMaxHeight({
...MAX_HEIGHT_ARGS,
windowHeight: 400,
keyboardHeight: 100,
})
).toBe(MIN);
});

it('never returns a negative height on a degenerate window', () => {
expect(
resolveComposerMaxHeight({
...MAX_HEIGHT_ARGS,
windowHeight: 300,
keyboardHeight: 400,
})
).toBe(MIN);
});
});
69 changes: 69 additions & 0 deletions apps/mobile/src/components/agents/chat-composer-input-height.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,39 @@ export const COMPOSER_INPUT_PADDING_HORIZONTAL = 16;
/** … and the 1px `border` on the wrapper View that `onLayout` measures. */
const COMPOSER_INPUT_BORDER_WIDTH = 1;

/**
* Screen chrome above the composer that the remaining-space cap must not eat:
* the `ScreenHeader` (back-button row ~44 + padding ~20) and the
* `SessionConnectionIndicator` (~28), excluding the safe-area top inset which
* the cap subtracts separately.
*/
export const SESSION_HEADER_HEIGHT = 92;

/**
* Starter chips: the empty session shows four chips that wrap to two rows.
* Budget two rows of ~48 min height each plus the 8px gap and 8px bottom
* padding (112). Kept out of the input's remaining-space budget so the input
* stays clear of the keyboard with starters shown.
*/
export const STARTER_ROW_HEIGHT = 112;

/**
* Composer chrome other than the input itself, kept out of the input's
* remaining-space budget: the control-row padding (20), the toolbar (44), the
* counter (16), the starter rows (112), and a reserve for the attachment strip
* (40). Conservative so the input stays clear of the keyboard at every Dynamic
* Type scale.
*/
export const COMPOSER_CHROME_HEIGHT = 120 + STARTER_ROW_HEIGHT;

/**
* New-session prompt chrome other than the input: the control row + toolbar +
* attachment strip + Start button + starter row. The prompt lives in a
* scrollable form, so the cap is a soft bound that keeps the input from
* pushing the Start control off-screen at large text.
*/
export const NEW_SESSION_PROMPT_CHROME_HEIGHT = 176 + STARTER_ROW_HEIGHT;

/**
* Width of the real text area inside the composer input row.
*
Expand All @@ -25,3 +58,39 @@ export function resolveComposerTextContentWidth(wrapperWidth: number): number {
export function shouldEnableComposerInputScroll(height: number, maxHeight: number): boolean {
return height >= maxHeight;
}

/**
* 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.
*/
export function resolveComposerMaxHeight({
windowHeight,
safeAreaInsetTop,
safeAreaInsetBottom,
keyboardHeight,
sessionHeaderHeight,
composerChromeHeight,
minHeight,
}: {
windowHeight: number;
safeAreaInsetTop: number;
safeAreaInsetBottom: number;
keyboardHeight: number;
sessionHeaderHeight: number;
composerChromeHeight: number;
minHeight: number;
}): number {
const remaining =
windowHeight -
safeAreaInsetTop -
safeAreaInsetBottom -
keyboardHeight -
sessionHeaderHeight -
composerChromeHeight;
return Math.max(minHeight, Math.floor(remaining));
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as composer-paste-button.mounted.test.tsx) */
import { createElement } from 'react';
import TestRenderer, { act } from 'react-test-renderer';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { ChatComposerInputRow } from './chat-composer-input-row';

vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
Pressable: 'Pressable',
TextInput: 'TextInput',
View: 'View',
}));
const platformOS = vi.hoisted(() => ({ os: 'ios' }));

function reactNativeMock() {
return {
ActivityIndicator: 'ActivityIndicator',
Platform: { OS: platformOS.os },
Pressable: 'Pressable',
TextInput: 'TextInput',
View: 'View',
};
}

vi.mock('react-native', reactNativeMock);
vi.mock('react-native-reanimated', () => ({
default: { View: 'Animated.View' },
FadeIn: { duration: vi.fn(() => ({})) },
Expand All @@ -19,6 +26,7 @@ vi.mock('react-native-reanimated', () => ({
}));
vi.mock('@/components/ui/icons', () => ({
ArrowUp: 'ArrowUp',
CornerDownLeft: 'CornerDownLeft',
Paperclip: 'Paperclip',
Square: 'Square',
}));
Expand All @@ -37,6 +45,8 @@ type RenderProps = {
hasSendableContent?: boolean;
inputEditable: boolean;
isStreaming?: boolean;
onSubmit?: () => void;
returnSendsMessage?: boolean;
voiceInputAvailable?: boolean;
};

Expand All @@ -58,12 +68,14 @@ function makeProps(overrides: Partial<RenderProps> = {}) {
onInputBlur: () => undefined,
onInputFocus: () => undefined,
onInputLayout: () => undefined,
onInsertNewline: () => undefined,
onSelectionChange: () => undefined,
onStop: () => undefined,
onSubmit: () => undefined,
onToggleVoice: () => undefined,
paperclipDisabled: false,
placeholder: 'Message the agent',
returnSendsMessage: false,
textInputStyle: {},
voiceDisabled: false,
voiceInputAvailable: false,
Expand Down Expand Up @@ -107,6 +119,10 @@ async function renderRow(props: RenderProps): Promise<TestRenderer.ReactTestRend
}

describe('ChatComposerInputRow mounted — iOS writing-tools lock', () => {
beforeEach(() => {
platformOS.os = 'ios';
});

it('blocks writing tools and selection when the input is not editable', async () => {
const renderer = await renderRow({ inputEditable: false });

Expand Down Expand Up @@ -174,6 +190,105 @@ describe('ChatComposerInputRow mounted — iOS writing-tools lock', () => {
renderer.unmount();
});

it('renders the newline control and wires return-submit when Return sends', async () => {
const onSubmit = vi.fn<() => void>();
const renderer = await renderRow({
inputEditable: true,
returnSendsMessage: true,
onSubmit,
});

expect(findAllByType(renderer.root, 'CornerDownLeft')).toHaveLength(1);
expect(findByAccessibilityLabel(renderer.root, 'Insert newline')).not.toBeNull();

const input = findTextInput(renderer.root);
expect(input.props.returnKeyType).toBe('send');
expect(input.props.submitBehavior).toBe('submit');

(input.props.onSubmitEditing as () => void)();
expect(onSubmit).toHaveBeenCalledTimes(1);

renderer.unmount();
});

it('omits the newline control and keeps newline submit when Return does not send', async () => {
const renderer = await renderRow({ inputEditable: true, returnSendsMessage: false });

expect(findAllByType(renderer.root, 'CornerDownLeft')).toHaveLength(0);

const input = findTextInput(renderer.root);
expect(input.props.returnKeyType).toBe('default');
expect(input.props.submitBehavior).toBe('newline');
expect(input.props.onSubmitEditing).toBeUndefined();

renderer.unmount();
});

it('sizes the send and stop pressables to the 44pt iOS hit target', async () => {
const sendRenderer = await renderRow({ inputEditable: true });
const send = findByAccessibilityLabel(sendRenderer.root, 'Send message');
const sendStyle = send?.props.style as { height: number; width: number } | undefined;
expect(sendStyle?.height).toBe(44);
expect(sendStyle?.width).toBe(44);
sendRenderer.unmount();

const stopRenderer = await renderRow({
inputEditable: true,
isStreaming: true,
canSend: false,
hasSendableContent: false,
});
const stop = findByAccessibilityLabel(stopRenderer.root, 'Stop generating');
const stopStyle = stop?.props.style as { height: number; width: number } | undefined;
expect(stopStyle?.height).toBe(44);
expect(stopStyle?.width).toBe(44);
stopRenderer.unmount();
});

it('sizes the send and stop pressables to the 48dp Android hit target', async () => {
platformOS.os = 'android';
// `CONTROL_HIT_TARGET` is a module-level constant, so the row must be
// re-imported after the platform flips to pick up the Android size. The
// persistent `vi.mock` factory is cached, so `doMock` re-registers it for
// the fresh import and `resetModules` clears the module cache.
vi.doMock('react-native', reactNativeMock);
vi.resetModules();
const { ChatComposerInputRow: AndroidRow } = await import('./chat-composer-input-row');
const { createElement: createElementAndroid } = await import('react');
const { default: Renderer, act: actFresh } = await import('react-test-renderer');

const renderAndroid = async (props: RenderProps): Promise<TestRenderer.ReactTestRenderer> => {
const holder: { current?: TestRenderer.ReactTestRenderer } = {};
await actFresh(async () => {
await Promise.resolve();
holder.current = Renderer.create(createElementAndroid(AndroidRow, makeProps(props)));
});
if (!holder.current) {
throw new Error('renderer was not created');
}
return holder.current;
};

const sendRenderer = await renderAndroid({ inputEditable: true });
const send = findByAccessibilityLabel(sendRenderer.root, 'Send message');
const sendStyle = send?.props.style as { height: number; width: number } | undefined;
expect(sendStyle?.height).toBe(48);
expect(sendStyle?.width).toBe(48);
sendRenderer.unmount();

const stopRenderer = await renderAndroid({
inputEditable: true,
isStreaming: true,
canSend: false,
hasSendableContent: false,
});
const stop = findByAccessibilityLabel(stopRenderer.root, 'Stop generating');
const stopStyle = stop?.props.style as { height: number; width: number } | undefined;
expect(stopStyle?.height).toBe(48);
expect(stopStyle?.width).toBe(48);
stopRenderer.unmount();
});

it('renders the mic at the lg size so it reaches the 48dp Android target', async () => {
const renderer = await renderRow({
inputEditable: true,
Expand Down
Loading