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" - /> - ) : ( - - )} ); }