diff --git a/apps/mobile/plugins/branded-splash.test.ts b/apps/mobile/plugins/branded-splash.test.ts
index 67e741368b..830f241802 100644
--- a/apps/mobile/plugins/branded-splash.test.ts
+++ b/apps/mobile/plugins/branded-splash.test.ts
@@ -130,7 +130,9 @@ describe('shared branded splash', () => {
// fold a developer's generated, gitignored `android/` tree into the result —
// its `colors.xml` is absent in CI — and merge colors this test does not own
// into the mod results, so the run would no longer describe only this
- // plugin's output.
+ // plugin's output. The colors array is asserted by containment for the same
+ // reason: the project's own other theme colors (iconBackground, colorPrimary,
+ // …) can ride along without failing this case.
const { root } = createAndroidProject();
const config: ExportedConfig = withBrandedSplash(
{ name: 'Kilo', slug: 'kilo-app', _internal: { projectRoot } },
diff --git a/apps/mobile/src/components/agents/new-session-configure-form.mounted.test.tsx b/apps/mobile/src/components/agents/new-session-configure-form.mounted.test.tsx
new file mode 100644
index 0000000000..0a401c85b3
--- /dev/null
+++ b/apps/mobile/src/components/agents/new-session-configure-form.mounted.test.tsx
@@ -0,0 +1,160 @@
+import { createElement } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { act, TestRenderer } from '@/test/renderer';
+
+import '@/i18n';
+
+import { NewSessionConfigureForm } from './new-session-configure-form';
+
+vi.mock('react-native', () => ({
+ I18nManager: { isRTL: false },
+ Keyboard: { addListener: vi.fn(() => ({ remove: vi.fn() })) },
+ Platform: { OS: 'ios' },
+ ScrollView: 'ScrollView',
+ View: 'View',
+}));
+
+vi.mock('react-native-safe-area-context', () => ({
+ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
+}));
+
+vi.mock('@/components/kilo-chat/app-aware-keyboard-padding', () => ({
+ AppAwareKeyboardPaddingView: 'AppAwareKeyboardPaddingView',
+}));
+
+// The composer card is the observation point, not the subject: its props carry
+// the frame geometry this suite asserts.
+vi.mock('@/components/agents/new-session-prompt', () => ({
+ NewSessionPrompt: 'NewSessionPrompt',
+}));
+
+vi.mock('@/components/agents/folder-selector', () => ({ LaunchFolderField: 'LaunchFolderField' }));
+vi.mock('@/components/agents/new-session-cloud-create-error', () => ({
+ NewSessionCloudCreateError: 'NewSessionCloudCreateError',
+}));
+vi.mock('@/components/agents/new-session-repository-section', () => ({
+ NewSessionRepositorySection: 'NewSessionRepositorySection',
+}));
+vi.mock('@/components/agents/new-session-run-target', () => ({
+ NewSessionRunTarget: 'NewSessionRunTarget',
+}));
+vi.mock('@/components/agents/new-session-start-button', () => ({
+ NewSessionStartButton: 'NewSessionStartButton',
+}));
+vi.mock('@/components/ui/button', () => ({ Button: 'Button' }));
+vi.mock('@/components/ui/icons', () => ({ RefreshCw: 'RefreshCw' }));
+vi.mock('@/components/ui/segmented-control', () => ({ SegmentedControl: 'SegmentedControl' }));
+vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' }));
+vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));
+
+type Instance = TestRenderer.ReactTestInstance;
+type LayoutHandler = (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
+
+/** The typed `onLayout` a host carries, so firing it is not an `any` call. */
+function layoutHandler(node: Instance | undefined): LayoutHandler | null {
+ const handler = node?.props.onLayout as LayoutHandler | undefined;
+ return handler ?? null;
+}
+
+function defaultProps() {
+ return {
+ attachments: [] as never[],
+ attachmentMax: 5,
+ isCreating: false,
+ isModelsError: false,
+ isLoadingModels: false,
+ mode: 'code' as const,
+ model: 'anthropic/claude-sonnet-4',
+ variant: 'medium',
+ modelOptions: [] as never[],
+ onChangeText: vi.fn(),
+ onModeChange: vi.fn(),
+ onModelSelect: vi.fn(),
+ onAddAttachment: vi.fn(),
+ onRemoveAttachment: vi.fn(),
+ onRetryAttachment: vi.fn(),
+ onMoveAttachment: vi.fn(),
+ onReorderAttachments: vi.fn(),
+ onRefetchModels: vi.fn(),
+ onPrefillAttachments: vi.fn(),
+ shareId: undefined as string | undefined,
+ voiceInputSettlerRef: { current: null },
+ showRunOnSelector: false,
+ runOnInstance: null,
+ instanceList: [] as never[],
+ isLoadingInstances: false,
+ isFetchingInstances: false,
+ onRefreshInstances: vi.fn(),
+ onChangeRunOnInstance: vi.fn(),
+ showInstanceDisconnectedNote: false,
+ folderPath: '',
+ onChangeFolderPath: vi.fn(),
+ groups: [] as never[],
+ isRetrying: false,
+ onChangeRepo: vi.fn(),
+ onConnectProvider: vi.fn(),
+ onRefreshRepos: vi.fn(),
+ repositories: [] as never[],
+ recents: [] as never[],
+ selectedRepo: '',
+ organizationId: undefined as string | undefined,
+ profile: null,
+ isProfileLoading: false,
+ isProfileError: false,
+ onRetryProfile: vi.fn(),
+ autoCommit: false,
+ onAutoCommitChange: vi.fn(),
+ isSpawningRemote: false,
+ isStartDisabled: false,
+ onStartSession: vi.fn(),
+ };
+}
+
+function findAllByType(root: Instance, type: string): Instance[] {
+ return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type);
+}
+
+/** The first host below the ScrollView carrying `onLayout` — the composer wrapper. */
+function findComposerWrapper(root: Instance): Instance | undefined {
+ return root.findAll(
+ node =>
+ typeof node.type === 'string' &&
+ (node.type as string) !== 'ScrollView' &&
+ typeof node.props.onLayout === 'function'
+ )[0];
+}
+
+describe('NewSessionConfigureForm composer frame geometry', () => {
+ it('threads the card top gap from the composer wrapper into NewSessionPrompt', async () => {
+ const holder: { current?: TestRenderer.ReactTestRenderer } = {};
+ await act(() => {
+ holder.current = TestRenderer.create(createElement(NewSessionConfigureForm, defaultProps()));
+ });
+ const renderer = holder.current;
+ if (!renderer) {
+ throw new Error('renderer was not created');
+ }
+
+ const scrollView = findAllByType(renderer.root, 'ScrollView')[0];
+ const composerWrapper = findComposerWrapper(renderer.root);
+ expect(scrollView).toBeDefined();
+ expect(composerWrapper).toBeDefined();
+
+ await act(() => {
+ // The lifted frame the keyboard leaves.
+ layoutHandler(scrollView)?.({ nativeEvent: { layout: { y: 0, height: 380 } } });
+ // The content container's `pt-4` inset puts the card 16pt into the frame.
+ layoutHandler(composerWrapper)?.({ nativeEvent: { layout: { y: 16, height: 420 } } });
+ });
+
+ const prompt = findAllByType(renderer.root, 'NewSessionPrompt')[0];
+ expect(prompt).toBeDefined();
+ expect(prompt?.props.frameHeight).toBe(380);
+ // Regression: the prompt's own onLayout reads 0 against the padding-free
+ // wrapper, so the host must pass the wrapper's frame offset instead.
+ expect(prompt?.props.cardTop).toBe(16);
+
+ renderer.unmount();
+ });
+});
diff --git a/apps/mobile/src/components/agents/new-session-configure-form.test.ts b/apps/mobile/src/components/agents/new-session-configure-form.test.ts
index d37ef8051d..87fd818b75 100644
--- a/apps/mobile/src/components/agents/new-session-configure-form.test.ts
+++ b/apps/mobile/src/components/agents/new-session-configure-form.test.ts
@@ -986,6 +986,19 @@ describe('NewSessionConfigureForm', () => {
expect(findElementByType(remote, 'NewSessionCloudCreateError')).toBeNull();
});
+ // ── Case 15: the scroll frame is measured and handed to the prompt ──
+ it('measures the scroll frame and threads it to NewSessionPrompt', async () => {
+ const { NewSessionConfigureForm } = await import('./new-session-configure-form');
+
+ // eslint-disable-next-line new-cap -- plain function call, matching repo test convention
+ const element = NewSessionConfigureForm(defaultProps()) as Node;
+
+ // The ScrollView reports its height so the prompt can yield its floor to it.
+ expect(findElementByType(element, 'ScrollView')?.onLayout).toEqual(expect.any(Function));
+ // The mocked useState holds the initial measurement (0) and never setStates.
+ expect(findElementByType(element, 'NewSessionPrompt')?.frameHeight).toBe(0);
+ });
+
// ── Case 16: reveal the composer card's bottom row above the IME ──
it('scrolls the composer card bottom above the keyboard once it opens', async () => {
const { NewSessionConfigureForm } = await import('./new-session-configure-form');
diff --git a/apps/mobile/src/components/agents/new-session-configure-form.tsx b/apps/mobile/src/components/agents/new-session-configure-form.tsx
index dac9dfb8c0..ed16bf4b8a 100644
--- a/apps/mobile/src/components/agents/new-session-configure-form.tsx
+++ b/apps/mobile/src/components/agents/new-session-configure-form.tsx
@@ -127,21 +127,25 @@ export function NewSessionConfigureForm({
// triggers: a formSheet anchors over the keyboard that is up at its first
// layout and never re-anchors, so the keyboard must be dismissed before
// the sheet opens.)
+ // The scroll frame's own height, reported by the ScrollView below. It already
+ // shrinks with the keyboard because `AppAwareKeyboardPaddingView` pads this
+ // parent; the prompt yields its minimum height to it so the whole composer
+ // card renders above the bottom system bar.
+ const [frameHeight, setFrameHeight] = useState(0);
+ // The composer card's top offset inside the scroll content, reported by the
+ // wrapper below and threaded to the prompt. The prompt's own `onLayout` reads
+ // `0` against that padding-free wrapper, dropping the content container's
+ // `pt-4` gap and overstating the room the frame leaves for the input.
+ const [composerTop, setComposerTop] = useState(0);
const isRemote = runOnInstance !== null;
- // The frame this form scrolls in: the height left once the navigation-bar
- // inset and the keyboard-lift padding are taken out. `NewSessionPrompt`
- // measures its input's min-height floor against this frame, so the input
- // gives lines up to the keyboard and takes them back when it leaves — the
- // window-based floor could not, because the window never resizes for the IME.
- const [promptViewportHeight, setPromptViewportHeight] = useState(0);
const isStarting = isRemote ? isSpawningRemote : isCreating;
const runOnNote =
runOnInlineNote ??
(showInstanceDisconnectedNote ? remoteSpawnInstanceDisconnectedNote() : null);
- function handleScrollViewportLayout(event: LayoutChangeEvent) {
- const nextHeight = Math.round(event.nativeEvent.layout.height);
- setPromptViewportHeight(current => (current === nextHeight ? current : nextHeight));
+ function handleScrollFrameLayout(event: LayoutChangeEvent) {
+ const next = Math.max(Math.round(event.nativeEvent.layout.height), 0);
+ setFrameHeight(current => (current === next ? current : next));
}
const body = (
@@ -155,7 +159,7 @@ export function NewSessionConfigureForm({
onLayout={event => {
// The one layout feeds both consumers: the form frame height sets the
// prompt's input floor, and the hook's viewport height drives the reveal.
- handleScrollViewportLayout(event);
+ handleScrollFrameLayout(event);
composerReveal.onViewportLayout(event.nativeEvent.layout.height);
}}
onScroll={event => {
@@ -172,6 +176,8 @@ export function NewSessionConfigureForm({
y: event.nativeEvent.layout.y,
height: event.nativeEvent.layout.height,
});
+ const nextTop = Math.max(Math.round(event.nativeEvent.layout.y), 0);
+ setComposerTop(current => (current === nextTop ? current : nextTop));
}}
>
diff --git a/apps/mobile/src/components/agents/new-session-prompt-fit.test.ts b/apps/mobile/src/components/agents/new-session-prompt-fit.test.ts
new file mode 100644
index 0000000000..98509b6ce2
--- /dev/null
+++ b/apps/mobile/src/components/agents/new-session-prompt-fit.test.ts
@@ -0,0 +1,136 @@
+import { describe, expect, it } from 'vitest';
+
+import { resolveNewSessionPromptMinHeight } from './new-session-prompt-fit';
+
+const LINE_HEIGHT = 24;
+const PREFERRED_LINES = 3;
+const PREFERRED_MIN_HEIGHT = LINE_HEIGHT * PREFERRED_LINES + 16;
+
+const PREFERRED = {
+ preferredMinHeight: PREFERRED_MIN_HEIGHT,
+ lineHeight: LINE_HEIGHT,
+ preferredLines: PREFERRED_LINES,
+} as const;
+
+/**
+ * The explorer finding's geometry, in points: the card sat 439px in a 410px
+ * frame — its bottom 61px (≈27dp) behind the system bar — with the input at
+ * its 3-line floor. The card's own chrome is ~103dp and it starts ~14dp below
+ * the frame top.
+ */
+const EXPLORER = {
+ frameHeight: 178,
+ cardTop: 14,
+ cardChromeHeight: 103,
+} as const;
+
+describe('resolveNewSessionPromptMinHeight', () => {
+ it('yields whole lines on the explorer frame so the whole card fits', () => {
+ // Precondition: the preferred floor cannot fit, so the defect is present.
+ expect(EXPLORER.cardTop + EXPLORER.cardChromeHeight + PREFERRED_MIN_HEIGHT).toBeGreaterThan(
+ EXPLORER.frameHeight
+ );
+
+ const fitted = resolveNewSessionPromptMinHeight({ ...PREFERRED, ...EXPLORER });
+
+ // 178 - 14 - 103 = 61 available, padding 16 -> one 24pt line: 40.
+ expect(fitted).toBe(40);
+ expect(fitted).toBeLessThan(PREFERRED_MIN_HEIGHT);
+ // The card, at the fitted floor, sits entirely inside the frame.
+ expect(EXPLORER.cardTop + EXPLORER.cardChromeHeight + fitted).toBeLessThanOrEqual(
+ EXPLORER.frameHeight
+ );
+ });
+
+ it('yields one line less once the card top gap crosses a whole-line boundary', () => {
+ // 195 - 0 - 103 = 92 available: three lines fit, so this frame keeps the
+ // preferred floor only because the card top gap is zero. The card really
+ // starts ~14pt below the frame top, which leaves 78 -> two lines. Reading
+ // the gap as 0 overstates the room by exactly that line.
+ const frameHeight = 195;
+ const withoutGap = resolveNewSessionPromptMinHeight({
+ ...PREFERRED,
+ ...EXPLORER,
+ frameHeight,
+ cardTop: 0,
+ });
+ const withGap = resolveNewSessionPromptMinHeight({ ...PREFERRED, ...EXPLORER, frameHeight });
+
+ expect(withoutGap).toBe(PREFERRED_MIN_HEIGHT);
+ expect(withGap).toBe(PREFERRED_MIN_HEIGHT - LINE_HEIGHT);
+ });
+
+ it('always returns a whole number of lines plus the preferred padding', () => {
+ const padding = PREFERRED_MIN_HEIGHT - LINE_HEIGHT * PREFERRED_LINES;
+
+ for (const frameHeight of [120, 150, 178, 205, 260, 400]) {
+ const fitted = resolveNewSessionPromptMinHeight({ ...PREFERRED, ...EXPLORER, frameHeight });
+ expect((fitted - padding) % LINE_HEIGHT).toBe(0);
+ expect(fitted).toBeGreaterThanOrEqual(LINE_HEIGHT + padding);
+ }
+ });
+
+ it('keeps the 3-line start when the frame has room', () => {
+ expect(resolveNewSessionPromptMinHeight({ ...PREFERRED, ...EXPLORER, frameHeight: 400 })).toBe(
+ PREFERRED_MIN_HEIGHT
+ );
+ });
+
+ it('never drops below one whole line, even when the frame cannot fit one', () => {
+ expect(resolveNewSessionPromptMinHeight({ ...PREFERRED, ...EXPLORER, frameHeight: 100 })).toBe(
+ LINE_HEIGHT + (PREFERRED_MIN_HEIGHT - LINE_HEIGHT * PREFERRED_LINES)
+ );
+ });
+
+ it.each([
+ { name: 'an unmeasured frame', overrides: { frameHeight: 0 } },
+ { name: 'a non-finite frame', overrides: { frameHeight: Number.NaN } },
+ { name: 'an unmeasured chrome', overrides: { cardChromeHeight: 0 } },
+ { name: 'a non-positive line height', overrides: { lineHeight: 0 } },
+ { name: 'no preferred lines', overrides: { preferredLines: 0 } },
+ { name: 'a negative card top', overrides: { cardTop: -1 } },
+ ])('returns the preferred floor for $name', ({ overrides }) => {
+ expect(resolveNewSessionPromptMinHeight({ ...PREFERRED, ...EXPLORER, ...overrides })).toBe(
+ PREFERRED_MIN_HEIGHT
+ );
+ });
+
+ it('returns the given floor unchanged when it is itself non-positive', () => {
+ expect(
+ resolveNewSessionPromptMinHeight({ ...PREFERRED, ...EXPLORER, preferredMinHeight: 0 })
+ ).toBe(0);
+ });
+
+ it('lowers the floor as the frame shrinks and never exceeds the preferred floor', () => {
+ const roomy = resolveNewSessionPromptMinHeight({ ...PREFERRED, ...EXPLORER, frameHeight: 260 });
+ const tight = resolveNewSessionPromptMinHeight({ ...PREFERRED, ...EXPLORER, frameHeight: 178 });
+ const cramped = resolveNewSessionPromptMinHeight({
+ ...PREFERRED,
+ ...EXPLORER,
+ frameHeight: 120,
+ });
+
+ expect(roomy).toBeGreaterThanOrEqual(tight);
+ expect(tight).toBeGreaterThanOrEqual(cramped);
+ expect(roomy).toBeLessThanOrEqual(PREFERRED_MIN_HEIGHT);
+ });
+
+ it('a taller error row only lowers the fitted floor', () => {
+ // A frame with room for the preferred floor, so adding the error row's
+ // height must cross a whole-line boundary instead of clamping to the
+ // one-line floor (where plain and error-row results would be equal).
+ const frameHeight = 225;
+ const plain = resolveNewSessionPromptMinHeight({ ...PREFERRED, ...EXPLORER, frameHeight });
+ const withErrorRow = resolveNewSessionPromptMinHeight({
+ ...PREFERRED,
+ ...EXPLORER,
+ frameHeight,
+ cardChromeHeight: EXPLORER.cardChromeHeight + 40,
+ });
+
+ expect(plain).toBe(PREFERRED_MIN_HEIGHT);
+ expect(withErrorRow).toBeLessThan(plain);
+ expect(withErrorRow).toBe(PREFERRED_MIN_HEIGHT - LINE_HEIGHT);
+ expect(withErrorRow).toBeGreaterThanOrEqual(LINE_HEIGHT + 16);
+ });
+});
diff --git a/apps/mobile/src/components/agents/new-session-prompt-fit.ts b/apps/mobile/src/components/agents/new-session-prompt-fit.ts
new file mode 100644
index 0000000000..6c3c17b5c2
--- /dev/null
+++ b/apps/mobile/src/components/agents/new-session-prompt-fit.ts
@@ -0,0 +1,70 @@
+/**
+ * Fits the new-session prompt's minimum height to the form frame it sits in.
+ *
+ * The prompt starts at a comfortable multi-line floor, but the form is a
+ * `ScrollView` that shrinks with the keyboard and the safe areas, and the card
+ * below the input is only as tall as the input plus its own chrome. When the
+ * floor plus that chrome no longer fit the frame, the card's bottom row (the
+ * attach/paste/voice controls) is pushed behind the bottom system bar. Yielding
+ * the floor to whole lines keeps the entire card inside the frame, and whole
+ * lines only, so the input never shows a clipped line.
+ *
+ * Pure: it reads no layout and mutates nothing, so the caller can recompute it
+ * on every frame change without shifting layout on load, refresh, or retry.
+ */
+
+export type ResolveNewSessionPromptMinHeightInput = {
+ /** Height of the scrollable form frame the prompt sits in, in points. */
+ frameHeight: number;
+ /** The card's top offset inside that frame, in points. */
+ cardTop: number;
+ /** Everything the card renders other than the input itself, in points. */
+ cardChromeHeight: number;
+ /** The multi-line floor used whenever the frame has room, in points. */
+ preferredMinHeight: number;
+ /** One text line's height at the current Dynamic Type scale, in points. */
+ lineHeight: number;
+ /** Number of lines `preferredMinHeight` reserves. */
+ preferredLines: number;
+};
+
+/**
+ * Largest whole-line height that still fits the space the frame leaves for the
+ * input, clamped to `preferredMinHeight` so a roomy frame keeps the preferred
+ * floor. Never returns fewer than one line. An unknown or non-positive
+ * measurement (the first layout pass, a hidden frame) returns
+ * `preferredMinHeight` unchanged, so the prompt renders at its preferred floor
+ * until the frame is measurable.
+ */
+export function resolveNewSessionPromptMinHeight({
+ frameHeight,
+ cardTop,
+ cardChromeHeight,
+ preferredMinHeight,
+ lineHeight,
+ preferredLines,
+}: ResolveNewSessionPromptMinHeightInput): number {
+ if (
+ !Number.isFinite(frameHeight) ||
+ !Number.isFinite(cardTop) ||
+ !Number.isFinite(cardChromeHeight) ||
+ !Number.isFinite(preferredMinHeight) ||
+ !Number.isFinite(lineHeight) ||
+ !Number.isFinite(preferredLines) ||
+ frameHeight <= 0 ||
+ cardTop < 0 ||
+ cardChromeHeight <= 0 ||
+ preferredMinHeight <= 0 ||
+ lineHeight <= 0 ||
+ preferredLines < 1
+ ) {
+ return preferredMinHeight;
+ }
+
+ // The padding `preferredMinHeight` adds around its lines, derived from the
+ // existing values instead of a second constant that could drift.
+ const padding = preferredMinHeight - lineHeight * preferredLines;
+ const available = frameHeight - cardTop - cardChromeHeight;
+ const lines = Math.max(1, Math.floor((available - padding) / lineHeight));
+ return Math.min(preferredMinHeight, lines * lineHeight + padding);
+}
diff --git a/apps/mobile/src/components/agents/new-session-prompt-initial-prompt.test.ts b/apps/mobile/src/components/agents/new-session-prompt-initial-prompt.test.ts
index 572d467b6c..609eb8934a 100644
--- a/apps/mobile/src/components/agents/new-session-prompt-initial-prompt.test.ts
+++ b/apps/mobile/src/components/agents/new-session-prompt-initial-prompt.test.ts
@@ -143,7 +143,7 @@ vi.mock('@/components/agents/use-text-height', () => ({
const voiceInputAvailable = vi.hoisted(() => ({ current: false }));
vi.mock('@/components/query-error', () => ({
- QueryError: () => null,
+ QueryError: 'QueryError',
}));
vi.mock('@/components/voice-input-control', () => ({
@@ -246,6 +246,46 @@ function findElementByType(
return null;
}
+/**
+ * Index of the first `target` node in a pre-order walk, or null when absent.
+ * Follows the same descent convention as `findElementByType` (through the
+ * `Text` and `NewSessionPromptControls` function components) so the returned
+ * order matches how the tree renders, and siblings' subtrees are visited
+ * left-to-right.
+ */
+function findFirstIndexByType(node: Node, target: ElementType): number | null {
+ let order = 0;
+ const visit = (current: Node): number | null => {
+ if (current === null || typeof current !== 'object') {
+ return null;
+ }
+ const props = current.props ?? {};
+ const children = props.children;
+ const currentOrder = order;
+ order += 1;
+ const nodeType = (current as { type?: unknown }).type;
+ if (nodeType === target) {
+ return currentOrder;
+ }
+ if (nodeType === renderText) {
+ return visit(renderText(props as React.ComponentProps));
+ }
+ if (nodeType === renderPromptControls) {
+ return visit(
+ renderPromptControls(props as React.ComponentProps)
+ );
+ }
+ for (const child of Array.isArray(children) ? children : [children]) {
+ const found = visit(child as Node);
+ if (found !== null) {
+ return found;
+ }
+ }
+ return null;
+ };
+ return visit(node);
+}
+
function defaultProps() {
const voiceInputSettlerRef: React.RefObject<(() => Promise) | null> = {
current: null,
@@ -473,24 +513,41 @@ describe('NewSessionPrompt initialPrompt seed', () => {
expect(findElementByType(element, renderPromptControls)).toBeNull();
});
- it('shrinks the input min height to two lines when the viewport above the keyboard is short', async () => {
- const { NewSessionPrompt } = await import('./new-session-prompt');
- // 300 - 92 (header) - 125 (card chrome) = 83; floor((83 - 16) / 24) = 2 lines.
- dimensions.current = { ...dimensions.current, height: 300 };
+ it.each([{ isLoadingModels: false }, { isLoadingModels: true }])(
+ 'renders the mode/model row above the input (isLoadingModels: $isLoadingModels)',
+ async ({ isLoadingModels }) => {
+ const { NewSessionPrompt: renderPrompt } = await import('./new-session-prompt');
+ const element = renderPrompt({ ...defaultProps(), isLoadingModels }) as Node;
- // eslint-disable-next-line new-cap -- plain function call, matching repo test convention
- NewSessionPrompt(defaultProps());
+ const toolbarIndex = findFirstIndexByType(element, 'ChatToolbar') ?? -1;
+ const inputIndex = findFirstIndexByType(element, 'TextInput') ?? -1;
- expect(textHeightOptions.current).toMatchObject({ minHeight: 64 });
- expect(textHeightOptions.current?.minHeight).not.toBe(88);
+ expect(toolbarIndex).toBeGreaterThanOrEqual(0);
+ expect(inputIndex).toBeGreaterThanOrEqual(0);
+ expect(toolbarIndex).toBeLessThan(inputIndex);
+ }
+ );
+
+ it('renders the models error above the input', async () => {
+ const { NewSessionPrompt: renderPrompt } = await import('./new-session-prompt');
+ const element = renderPrompt({
+ ...defaultProps(),
+ isModelsError: true,
+ modelOptions: [],
+ }) as Node;
+
+ const errorIndex = findFirstIndexByType(element, 'QueryError') ?? -1;
+ const inputIndex = findFirstIndexByType(element, 'TextInput') ?? -1;
+
+ expect(errorIndex).toBeGreaterThanOrEqual(0);
+ expect(inputIndex).toBeGreaterThanOrEqual(0);
+ expect(errorIndex).toBeLessThan(inputIndex);
});
- it('keeps the three-line floor at a large font scale when the viewport has room', async () => {
+ it('keeps the three-line floor at a large font scale', async () => {
const { NewSessionPrompt } = await import('./new-session-prompt');
- // 500 - 92 (header, unscaled) - 145 (card chrome: 105 fixed + 20 text * 2)
- // = 263; floor((263 - 16) / 48) = 5 -> clamped to the three-line default.
- // Scaling the fixed rows (the shipped regression) gives 500 - 184 - 250 = 66
- // -> one line -> 64.
+ // Before the host reports a frame, the floor is the preferred three lines
+ // at the scaled line height: 24 * 2 * 3 + 16 = 160.
dimensions.current = { ...dimensions.current, fontScale: 2, height: 500 };
// eslint-disable-next-line new-cap -- plain function call, matching repo test convention
@@ -499,61 +556,4 @@ describe('NewSessionPrompt initialPrompt seed', () => {
expect(textHeightOptions.current).toMatchObject({ minHeight: 160 });
expect(textHeightOptions.current?.minHeight).not.toBe(64);
});
-
- it('lets the mode/model pills wrap and reports the toolbar height to the floor', async () => {
- const { NewSessionPrompt } = await import('./new-session-prompt');
-
- // eslint-disable-next-line new-cap -- plain function call, matching repo test convention
- const element = NewSessionPrompt(defaultProps()) as Node;
-
- // The pills wrap onto a second row when the row runs out of width, so the
- // toolbar is allowed to grow and is measured for the input's floor (the
- // fallback-or-measured chrome decision itself is pinned in
- // chat-composer-input-height.test.ts).
- const toolbarProps = findElementByType(element, 'ChatToolbar');
- expect(toolbarProps).toMatchObject({ wrap: true });
- expect(toolbarProps?.onLayout).toEqual(expect.any(Function));
- });
-
- it('reserves the attachment strip so the toolbar clears the keyboard with an attachment staged', async () => {
- const { NewSessionPrompt } = await import('./new-session-prompt');
- // The released density-560 frame: 203 - 125 (card chrome) - 12 = 66;
- // floor((66 - 16) / 24) = two lines.
- const promptViewportHeight = 203;
-
- // eslint-disable-next-line new-cap -- plain function call, matching repo test convention
- NewSessionPrompt({ ...defaultProps(), promptViewportHeight });
- expect(textHeightOptions.current).toMatchObject({ minHeight: 64 });
-
- // The strip's 72 leaves the frame room for one line, so the input gives the
- // line up instead of pushing the mode/model pills under the IME.
- // eslint-disable-next-line new-cap -- plain function call, matching repo test convention
- NewSessionPrompt({
- ...defaultProps(),
- attachments: [{ id: 'a1', metadataStripFailed: false }] as never[],
- promptViewportHeight,
- });
- expect(textHeightOptions.current).toMatchObject({ minHeight: 40 });
- });
-
- it('reserves the counter and the metadata notice above the toolbar in the floor', async () => {
- const { NewSessionPrompt } = await import('./new-session-prompt');
- const promptViewportHeight = 203;
-
- // eslint-disable-next-line new-cap -- plain function call, matching repo test convention
- NewSessionPrompt({
- ...defaultProps(),
- initialPrompt: 'x'.repeat(100_000 - 5),
- promptViewportHeight,
- });
- expect(textHeightOptions.current).toMatchObject({ minHeight: 40 });
-
- // eslint-disable-next-line new-cap -- plain function call, matching repo test convention
- NewSessionPrompt({
- ...defaultProps(),
- attachments: [{ id: 'a1', metadataStripFailed: true }] as never[],
- promptViewportHeight,
- });
- expect(textHeightOptions.current).toMatchObject({ minHeight: 40 });
- });
});
diff --git a/apps/mobile/src/components/agents/new-session-prompt-types.ts b/apps/mobile/src/components/agents/new-session-prompt-types.ts
index 8eb036ba03..0e4dec157e 100644
--- a/apps/mobile/src/components/agents/new-session-prompt-types.ts
+++ b/apps/mobile/src/components/agents/new-session-prompt-types.ts
@@ -46,13 +46,22 @@ export type NewSessionPromptProps = {
/** Optional initial prompt text seeded into the uncontrolled input once on mount. */
initialPrompt?: string;
/**
- * Height of the frame the prompt scrolls in, once the safe areas and the
- * keyboard-lift padding are out. The input's min-height floor measures
- * against this frame when it is known, so the input gives up lines while the
- * keyboard is up and grows back when it leaves. Omitted (or 0) falls back to
- * the window-based floor for the first frame, before the host has laid out.
+ * Height of the scrollable form frame the prompt sits in, in points. The
+ * prompt yields its minimum height to this frame so the whole card renders
+ * above the bottom system bar; omitted or unmeasured keeps the preferred
+ * floor.
*/
- promptViewportHeight?: number;
+ frameHeight?: number;
+ /**
+ * The card's top offset inside that frame, in points, measured by the host.
+ * The prompt's own `onLayout` reports `y` relative to the padding-free
+ * wrapper that hosts the card (`NewSessionConfigureForm`), so it always reads
+ * `0` and cannot see the frame's top gap (the ScrollView content's `pt-4`
+ * inset). The fit needs the frame-relative offset, so the host measures the
+ * wrapper and passes it here. Omitted or unmeasured keeps `0`, which restores
+ * the preferred floor.
+ */
+ cardTop?: number;
/** True for the Continue clone entry: hides the composer, paperclip, paste, and voice. */
isCloneEntry?: boolean;
};
diff --git a/apps/mobile/src/components/agents/new-session-prompt.tsx b/apps/mobile/src/components/agents/new-session-prompt.tsx
index b8bcc81ef1..ff58089cf3 100644
--- a/apps/mobile/src/components/agents/new-session-prompt.tsx
+++ b/apps/mobile/src/components/agents/new-session-prompt.tsx
@@ -35,14 +35,12 @@ import {
NEW_SESSION_PROMPT_LINE_HEIGHT,
NEW_SESSION_PROMPT_VERTICAL_PADDING,
resolveComposerMaxHeight,
- resolveComposerMinHeight,
- resolveComposerMinHeightForViewport,
- resolveNewSessionPromptCardChrome,
- resolveNewSessionPromptCardChromeRowsAboveToolbar,
+ resolveNewSessionPromptHeight,
SESSION_HEADER_HEIGHT,
} from '@/components/agents/chat-composer-input-height';
import { useReturnSendsMessagePreference } from '@/lib/hooks/use-return-sends-message-preference';
import { resolveNewSessionPromptControlState } from '@/components/agents/new-session-prompt-state';
+import { resolveNewSessionPromptMinHeight } from '@/components/agents/new-session-prompt-fit';
import { NewSessionPromptClone } from '@/components/agents/new-session-prompt-clone';
import { NewSessionPromptControls } from '@/components/agents/new-session-prompt-controls';
import { type NewSessionPromptProps } from '@/components/agents/new-session-prompt-types';
@@ -88,9 +86,9 @@ type NewSessionPromptComponentProps = NewSessionPromptProps & {
};
/**
- * New-session prompt surface: attachment strip, full-width multiline text
- * input, bottom action row (paperclip leading, voice toggle trailing), and
- * the model/mode toolbar. Owns the prompt ref (for voice input to read), the
+ * New-session prompt surface: model/mode toolbar, attachment strip,
+ * full-width multiline text input, and bottom action row (paperclip leading,
+ * voice toggle trailing). Owns the prompt ref (for voice input to read), the
* height-measuring TextInput machinery, and the `useVoiceInput` hook. The
* route listens to `onChangeText` so the create handler can read the live
* prompt value after `settleVoiceInputBeforeSubmit` resolves; the attachment,
@@ -123,7 +121,8 @@ export function NewSessionPrompt({
shareId,
voiceInputSettlerRef,
initialPrompt,
- promptViewportHeight = 0,
+ frameHeight,
+ cardTop = 0,
onStartSession,
isCloneEntry = false,
}: Readonly) {
@@ -153,66 +152,31 @@ export function NewSessionPrompt({
const isComposingRef = useRef(false);
const abortVoiceInputRef = useRef<(() => Promise) | null>(null);
const [promptInputWidth, setPromptInputWidth] = useState(0);
- // Measured mode/model toolbar height. The pills wrap onto a second row on
- // narrow viewports, so the chrome the input must not squeeze out is not a
- // constant; the static budget covers the first frame, before the toolbar
- // has laid out.
- const [toolbarHeight, setToolbarHeight] = useState(null);
const promptLineHeight = NEW_SESSION_PROMPT_LINE_HEIGHT * fontScale;
- // The card rows above the toolbar that render only in some states. The floor
- // below reserves each one under the same condition the render uses.
- const hasAttachments = attachments.length > 0;
+ // The card rows that render only in some states, read by name in the render
+ // below (the measured card chrome covers their height without a static
+ // budget, so they need no separate reservation here).
const showsAttachmentStatus = attachments.some(
attachment => attachment.metadataStripFailed === true
);
const showsCounter =
PROMPT_INPUT_MAX_CHARS - promptCharacterCount <= PROMPT_COUNTER_VISIBLE_REMAINING;
- // The models-error block renders in the toolbar's place.
- const showsModelsError = isModelsError && modelOptions.length === 0;
- // The input's floor gives up lines on a short viewport (landscape at a high
- // density) so the card's control row and mode/model toolbar stay above the
- // keyboard. With room it is still the three-line default. The floor scales
- // only the rows that grow with `fontScale` (the pill's text line); the header
- // row is pinned by its own `min-h-14` and does not. The host's measured frame
- // is the floor's yardstick once it exists; the window-based floor covers the
- // first frame, before the host has laid out. The max cap below stays
- // deliberately conservative — only the floor hands lines back.
- const promptCardChromeHeight = resolveNewSessionPromptCardChrome({
- fontScale,
- toolbarHeight,
- // The card renders three more rows above the toolbar in some states; the
- // floor reserves each with the same condition the render uses. A row it
- // does not reserve is a line the input keeps while the pills drop under
- // the keyboard.
- rowsAboveToolbarHeight: resolveNewSessionPromptCardChromeRowsAboveToolbar({
- hasAttachments,
- showsAttachmentStatus,
- showsCounter,
- }),
- // The models-error block renders in the toolbar's place, so the toolbar's
- // measured height must not stand in for a row that is not there.
- toolbarRendered: !showsModelsError,
+ // The chrome the card renders around the input, measured by the card's
+ // `onLayout` below. With the host-measured `cardTop` prop it sizes the space
+ // the frame leaves for the input.
+ const [cardChromeHeight, setCardChromeHeight] = useState(0);
+ const promptMinHeight = resolveNewSessionPromptMinHeight({
+ frameHeight: frameHeight ?? 0,
+ cardTop,
+ cardChromeHeight,
+ // The shared helper keeps the preferred 3-line floor in one place.
+ preferredMinHeight: resolveNewSessionPromptHeight(
+ promptLineHeight,
+ NEW_SESSION_PROMPT_DEFAULT_LINES
+ ),
+ lineHeight: promptLineHeight,
+ preferredLines: NEW_SESSION_PROMPT_DEFAULT_LINES,
});
- const promptMinHeight =
- promptViewportHeight > 0
- ? resolveComposerMinHeightForViewport({
- viewportHeight: promptViewportHeight,
- composerChromeHeight: promptCardChromeHeight,
- lineHeight: promptLineHeight,
- verticalPadding: NEW_SESSION_PROMPT_VERTICAL_PADDING,
- defaultLines: NEW_SESSION_PROMPT_DEFAULT_LINES,
- })
- : resolveComposerMinHeight({
- windowHeight,
- safeAreaInsetTop: insets.top,
- safeAreaInsetBottom: insets.bottom,
- keyboardHeight,
- sessionHeaderHeight: SESSION_HEADER_HEIGHT,
- composerChromeHeight: promptCardChromeHeight,
- lineHeight: promptLineHeight,
- verticalPadding: NEW_SESSION_PROMPT_VERTICAL_PADDING,
- defaultLines: NEW_SESSION_PROMPT_DEFAULT_LINES,
- });
const promptMaxHeight = resolveComposerMaxHeight({
windowHeight,
safeAreaInsetTop: insets.top,
@@ -363,14 +327,15 @@ export function NewSessionPrompt({
setPromptInputWidth(current => (current === nextWidth ? current : nextWidth));
}
- function handleToolbarLayout(event: LayoutChangeEvent) {
- const nextHeight = Math.round(event.nativeEvent.layout.height);
- // A zero-height layout is not a measurement — the row is collapsed or has
- // not laid out — so keep the last measured height (or the `null` that makes
- // the floor use the static budget). Reserving the rows above the toolbar
- // alone (68) would sit below the static estimate (125) and let the input
- // keep lines the card cannot fit.
- setToolbarHeight(current => (nextHeight <= 0 ? current : nextHeight));
+ function handleCardLayout(event: LayoutChangeEvent) {
+ const { height } = event.nativeEvent.layout;
+ // Everything the card renders other than the input itself. The input's
+ // height changes when the fit yields and the card's height changes with it,
+ // so this is stable across the yielding re-render. The card's `y` is not
+ // read here: it is relative to the padding-free host wrapper, so the host
+ // passes the frame-relative offset as `cardTop` instead.
+ const nextChromeHeight = height - promptMeasure.height;
+ setCardChromeHeight(current => (current === nextChromeHeight ? current : nextChromeHeight));
}
function handlePromptSelectionChange(event: TextInputSelectionChangeEvent) {
@@ -434,7 +399,37 @@ export function NewSessionPrompt({
}
return (
-
+
+ {isModelsError && modelOptions.length === 0 ? (
+ {
+ onRefetchModels();
+ }}
+ className="border-b border-border py-4"
+ />
+ ) : (
+
+ )}
- {showsModelsError ? (
- {
- onRefetchModels();
- }}
- className="border-t border-border py-4"
- />
- ) : (
-
- )}
);
}