diff --git a/apps/mobile/src/components/agents/chat-composer-attachment-send.test.ts b/apps/mobile/src/components/agents/chat-composer-attachment-send.test.ts index 06ad1347e5c..8ab96e5f6a1 100644 --- a/apps/mobile/src/components/agents/chat-composer-attachment-send.test.ts +++ b/apps/mobile/src/components/agents/chat-composer-attachment-send.test.ts @@ -175,6 +175,7 @@ vi.mock('@/components/agents/remote-session-exit-alert', () => ({ vi.mock('@/components/agents/use-text-height', () => ({ useTextHeight: () => ({ height: 88, + maxHeight: 124, measureElement: null, reset: vi.fn(), setText: vi.fn(), diff --git a/apps/mobile/src/components/agents/chat-composer-input-height.test.ts b/apps/mobile/src/components/agents/chat-composer-input-height.test.ts index 37797963e8b..73fabb12553 100644 --- a/apps/mobile/src/components/agents/chat-composer-input-height.test.ts +++ b/apps/mobile/src/components/agents/chat-composer-input-height.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { + alignComposerInputHeightToLines, COMPOSER_CHROME_HEIGHT, COMPOSER_INPUT_MAX_HEIGHT, COMPOSER_INPUT_PADDING_HORIZONTAL, @@ -172,6 +173,103 @@ describe('resolveComposerMaxHeight', () => { }); }); +describe('alignComposerInputHeightToLines', () => { + // The composer input's own geometry: 20pt lines and 24pt of vertical padding. + // `TEXT_INPUT_VERTICAL_PADDING` is not font-scaled (see `chat-composer.tsx`), + // so the aligned cap is a whole number of scaled lines plus a flat 24. + const LINE_HEIGHT = 20; + const VERTICAL_PADDING = 24; + + const capArgsFor = (windowHeight: number, fontScale: number) => + ({ + windowHeight, + safeAreaInsetTop: 0, + safeAreaInsetBottom: 0, + keyboardHeight: 0, + sessionHeaderHeight: 0, + composerChromeHeight: 0, + minHeight: LINE_HEIGHT * fontScale + VERTICAL_PADDING, + absoluteMaxHeight: 1000, + }) as const; + + const align = (height: number, fontScale: number) => { + const lineHeight = LINE_HEIGHT * fontScale; + return alignComposerInputHeightToLines({ + height, + lineHeight, + verticalPadding: VERTICAL_PADDING, + minHeight: lineHeight + VERTICAL_PADDING, + }); + }; + + // Window heights whose remaining space leaves a raw cap of 62, 98, and 117 + // points at fontScale 1: the capped composer in the report measured 117dp, + // not a whole number of lines, so Android scrolled to the caret by a partial + // line and the draft's first line was cut by the input's top edge. + it.each([62, 98, 117])( + 'snaps a %ipt cap to a whole number of lines at fontScale 1', + windowHeight => { + const cap = resolveComposerMaxHeight(capArgsFor(windowHeight, 1)); + // Precondition: the raw remaining-space cap is not line-aligned. + expect((cap - VERTICAL_PADDING) % LINE_HEIGHT).not.toBe(0); + + const aligned = align(cap, 1); + + expect((aligned - VERTICAL_PADDING) % LINE_HEIGHT).toBe(0); + expect(aligned).toBeLessThanOrEqual(cap); + expect(aligned).toBeGreaterThanOrEqual(LINE_HEIGHT + VERTICAL_PADDING); + } + ); + + it.each([1.5, 2])( + 'snaps a 117pt cap to a whole number of scaled lines at fontScale %s', + fontScale => { + const cap = resolveComposerMaxHeight(capArgsFor(117, fontScale)); + + const aligned = align(cap, fontScale); + + expect((aligned - VERTICAL_PADDING) % (LINE_HEIGHT * fontScale)).toBe(0); + expect(aligned).toBeLessThanOrEqual(cap); + expect(aligned).toBeGreaterThanOrEqual(LINE_HEIGHT * fontScale + VERTICAL_PADDING); + } + ); + + it('keeps the largest line-aligned height at or below the raw cap', () => { + // 117 -> 4 lines + padding = 104, the largest whole-line height under 117. + expect(align(117, 1)).toBe(104); + expect(align(98, 1)).toBe(84); + expect(align(62, 1)).toBe(44); + }); + + it('never falls below the minimum height', () => { + expect(align(30, 1)).toBe(LINE_HEIGHT + VERTICAL_PADDING); + expect(align(LINE_HEIGHT + VERTICAL_PADDING, 1)).toBe(LINE_HEIGHT + VERTICAL_PADDING); + }); + + it('leaves an already line-aligned cap unchanged', () => { + expect(align(124, 1)).toBe(124); + }); + + it('returns the height unchanged when the geometry is degenerate', () => { + expect( + alignComposerInputHeightToLines({ + height: 117, + lineHeight: 0, + verticalPadding: VERTICAL_PADDING, + minHeight: 44, + }) + ).toBe(117); + expect( + alignComposerInputHeightToLines({ + height: 117, + lineHeight: LINE_HEIGHT, + verticalPadding: 0, + minHeight: 44, + }) + ).toBe(117); + }); +}); + const MIN_HEIGHT_ARGS = { windowHeight: 1000, safeAreaInsetTop: 44, diff --git a/apps/mobile/src/components/agents/chat-composer-input-height.ts b/apps/mobile/src/components/agents/chat-composer-input-height.ts index 110da279e6d..fdf2887fbb5 100644 --- a/apps/mobile/src/components/agents/chat-composer-input-height.ts +++ b/apps/mobile/src/components/agents/chat-composer-input-height.ts @@ -284,6 +284,38 @@ export function resolveComposerMaxHeight({ return Math.max(minHeight, Math.min(Math.floor(remaining), absoluteMaxHeight)); } +/** + * Snap a capped composer input height down to a whole number of text lines. + * + * A capped multiline input must be `n * lineHeight + verticalPadding`: when the + * cap is not line-aligned, Android's `TextInput` scrolls to keep the caret (at + * the end of the draft) visible by a partial line, so the first visible line is + * painted cut by the input's top edge. Flooring the cap to a whole number of + * lines makes the scrolled content land on a line boundary instead. + * + * The result never falls below `minHeight`, and a degenerate geometry + * (non-positive line height or vertical padding) returns the height unchanged + * rather than dividing by zero. The caller passes the measured line pitch and + * the input's own unscaled vertical padding. + */ +export function alignComposerInputHeightToLines({ + height, + lineHeight, + verticalPadding, + minHeight, +}: { + height: number; + lineHeight: number; + verticalPadding: number; + minHeight: number; +}): number { + if (lineHeight <= 0 || verticalPadding <= 0) { + return height; + } + const lines = Math.floor((height - verticalPadding) / lineHeight); + return Math.max(minHeight, lines * lineHeight + verticalPadding); +} + /** * Clearance the measured-viewport floor keeps between the card's last row (the * mode/model toolbar) and the frame's bottom edge, in unscaled points. The diff --git a/apps/mobile/src/components/agents/chat-composer-input-row.mounted.test.tsx b/apps/mobile/src/components/agents/chat-composer-input-row.mounted.test.tsx index 0745b765d57..26d2cd2cfc3 100644 --- a/apps/mobile/src/components/agents/chat-composer-input-row.mounted.test.tsx +++ b/apps/mobile/src/components/agents/chat-composer-input-row.mounted.test.tsx @@ -72,6 +72,7 @@ function makeProps(overrides: Partial = {}) { onInputBlur: () => undefined, onInputFocus: () => undefined, onInputLayout: () => undefined, + onInputContentSizeChange: () => undefined, onInsertNewline: () => undefined, onSelectionChange: () => undefined, onStop: () => undefined, diff --git a/apps/mobile/src/components/agents/chat-composer-input-row.tsx b/apps/mobile/src/components/agents/chat-composer-input-row.tsx index 2020ad48c1c..65437b373b6 100644 --- a/apps/mobile/src/components/agents/chat-composer-input-row.tsx +++ b/apps/mobile/src/components/agents/chat-composer-input-row.tsx @@ -43,6 +43,12 @@ type ChatComposerInputRowProps = { onInputBlur: () => void; onInputFocus: () => void; onInputLayout: (event: LayoutChangeEvent) => void; + /** + * Report the input's own rendered content height in dp. The composer uses it + * as the one faithful measure of the pitch this input lays lines out at (see + * `useTextHeight`). + */ + onInputContentSizeChange: (contentHeight: number) => void; onInsertNewline: () => void; onSelectionChange: (event: TextInputSelectionChangeEvent) => void; onStop: () => void; @@ -81,6 +87,7 @@ export function ChatComposerInputRow({ onChangeText, onInputBlur, onInputFocus, + onInputContentSizeChange, onInputLayout, onInsertNewline, onSelectionChange, @@ -133,6 +140,9 @@ export function ChatComposerInputRow({ multiline maxLength={CLOUD_AGENT_PROMPT_MAX_LENGTH} onChangeText={onChangeText} + onContentSizeChange={event => { + onInputContentSizeChange(event.nativeEvent.contentSize.height); + }} onFocus={onInputFocus} onBlur={onInputBlur} onSelectionChange={onSelectionChange} diff --git a/apps/mobile/src/components/agents/chat-composer.test.ts b/apps/mobile/src/components/agents/chat-composer.test.ts index 3ee40646d7d..ec6b1eea0f8 100644 --- a/apps/mobile/src/components/agents/chat-composer.test.ts +++ b/apps/mobile/src/components/agents/chat-composer.test.ts @@ -222,6 +222,7 @@ vi.mock('@/components/agents/remote-session-exit-alert', () => ({ vi.mock('@/components/agents/use-text-height', () => ({ useTextHeight: () => ({ height: 88, + maxHeight: 124, measureElement: null, reset: vi.fn(), setText: vi.fn(), diff --git a/apps/mobile/src/components/agents/chat-composer.tsx b/apps/mobile/src/components/agents/chat-composer.tsx index ae1c87551f1..3d2c6910a32 100644 --- a/apps/mobile/src/components/agents/chat-composer.tsx +++ b/apps/mobile/src/components/agents/chat-composer.tsx @@ -391,7 +391,7 @@ export function ChatComposer({ const fontSize = TEXT_INPUT_FONT_SIZE * fontScale; const lineHeight = TEXT_INPUT_LINE_HEIGHT * fontScale; const inputMinHeight = lineHeight + TEXT_INPUT_VERTICAL_PADDING; - const inputMaxHeight = resolveComposerMaxHeight({ + const rawInputMaxHeight = resolveComposerMaxHeight({ windowHeight, safeAreaInsetTop: insets.top, safeAreaInsetBottom: insets.bottom, @@ -418,15 +418,26 @@ export function ChatComposer({ }; }, []); + // The row reports the real input's content height (dp, padding included) on + // every content change. `useTextHeight` reads the line pitch that input + // lays out at from it, so the capped height it publishes lands on a whole + // rendered line. + const [inputContentHeight, setInputContentHeight] = useState(null); const measure = useTextHeight({ minHeight: inputMinHeight, - maxHeight: inputMaxHeight, + maxHeight: rawInputMaxHeight, verticalPadding: TEXT_INPUT_VERTICAL_PADDING, textContentWidth: resolveComposerTextContentWidth(inputWidth), fontSize: TEXT_INPUT_FONT_SIZE, lineHeight: TEXT_INPUT_LINE_HEIGHT, fontScale, + nativeContentHeight: inputContentHeight, }); + // The cap snapped to the input's own rendered line pitch (see + // `useTextHeight`). A capped multiline input must be a whole number of that + // pitch: Android's `TextInput` scrolls to the caret by a partial line + // otherwise, which cuts the first visible line against the input's top edge. + const inputMaxHeight = measure.maxHeight; // useTextHeight() returns a new object every render. Hold the latest // measure in a ref so the draft-restore effect only runs after an // inputEpoch bump (remount), never on a stray measure identity change. @@ -1180,9 +1191,9 @@ export function ChatComposer({ const textInputStyle: TextStyle = { color: colors.foreground, fontSize, - height: measure.height, includeFontPadding: false, lineHeight, + height: measure.height, paddingHorizontal: COMPOSER_INPUT_PADDING_HORIZONTAL, paddingVertical: 12, textAlignVertical: 'top', @@ -1342,6 +1353,7 @@ export function ChatComposer({ setIsFocused(true); }} onInputLayout={handleInputLayout} + onInputContentSizeChange={setInputContentHeight} onInsertNewline={handleInsertNewline} onSelectionChange={handleSelectionChange} onStop={handleStop} 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 a45a15e9dd5..15953797b0f 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 @@ -133,6 +133,7 @@ vi.mock('@/components/agents/use-text-height', () => ({ textHeightOptions.current = options; return { height: 48, + maxHeight: 160, measureElement: null, reset: vi.fn(), setText: vi.fn(), diff --git a/apps/mobile/src/components/agents/new-session-prompt.tsx b/apps/mobile/src/components/agents/new-session-prompt.tsx index eec9ba362fc..63183765953 100644 --- a/apps/mobile/src/components/agents/new-session-prompt.tsx +++ b/apps/mobile/src/components/agents/new-session-prompt.tsx @@ -470,7 +470,7 @@ export function NewSessionPrompt({ onChangeText={handlePromptChange} onSelectionChange={handlePromptSelectionChange} onLayout={handlePromptInputLayout} - scrollEnabled={promptMeasure.height >= promptMaxHeight} + scrollEnabled={promptMeasure.height >= promptMeasure.maxHeight} maxLength={PROMPT_INPUT_MAX_CHARS} returnKeyType={returnSendsMessage ? 'send' : 'default'} submitBehavior={returnSendsMessage ? 'submit' : 'newline'} diff --git a/apps/mobile/src/components/agents/session-context-metrics.tsx b/apps/mobile/src/components/agents/session-context-metrics.tsx index f9f07ad3524..55065125b70 100644 --- a/apps/mobile/src/components/agents/session-context-metrics.tsx +++ b/apps/mobile/src/components/agents/session-context-metrics.tsx @@ -75,7 +75,9 @@ export function SessionContextMetrics({ // for the 44pt minimum touch target; height is identical in every pill state. // `shrink min-w-0` lets the pill compress inside the header's capped trailing // slot: RN's default flexShrink is 0, so without them the pill keeps its - // natural width and paints past the row's right edge, off-screen. + // natural width and paints past the row's right edge, off-screen. The cost is + // the only unbounded text in the pill, so both text slots truncate to one + // line instead of painting past the pill. const pillClassName = 'h-[44px] shrink min-w-0 flex-row items-center gap-2 rounded-full border border-border bg-secondary px-3'; diff --git a/apps/mobile/src/components/agents/session-detail-content.test.ts b/apps/mobile/src/components/agents/session-detail-content.test.ts index 42187560c3a..22e9da97856 100644 --- a/apps/mobile/src/components/agents/session-detail-content.test.ts +++ b/apps/mobile/src/components/agents/session-detail-content.test.ts @@ -49,8 +49,10 @@ import { setSessionGoalCollapsed, } from '@/components/agents/session-goal-collapse'; import { SessionDetailContent } from '@/components/agents/session-detail-content'; +import { SessionContextMetrics } from '@/components/agents/session-context-metrics'; import { SESSION_TITLE_MAX_LENGTH } from '@/components/agents/session-detail-rename-state'; import { SessionContextSheet } from '@/components/agents/session-context-sheet'; +import { formatSessionTotalCost } from '@/components/agents/session-list-helpers'; import { SessionGoalSection } from '@/components/agents/session-goal-section'; import { SessionSkeletonMessages } from '@/components/agents/session-detail-skeleton'; import { SESSION_SLOW_LOAD_MS } from '@/components/agents/session-slow-load'; @@ -1277,6 +1279,62 @@ describe('SessionDetailContent header title', () => { }); }); +describe('session detail header right cluster', () => { + it('caps the right cluster inside the header slot and renders no copy control', async () => { + const { renderer } = await mountDetails([]); + const header = renderer.root.findByType(ScreenHeader); + // The copy-link action left the header in #6343 (7fad4e808) and now lives + // in the context sheet, so the sliced chain-link control the explorer + // captured cannot paint here any more. + expect(header.findAll(node => Object.is(node.type, 'Link2'))).toHaveLength(0); + + // The header caps its right slot at half the row... + const slot = header.findAll( + node => + typeof node.props.className === 'string' && node.props.className.includes('max-w-[50%]') + ); + expect(slot).toHaveLength(1); + expect(slot[0]?.props.className).toContain('min-w-0'); + expect(slot[0]?.props.className).toContain('shrink'); + + // ...and the cluster inside it shrinks into that cap, so it can never paint + // past the slot edge. It holds the context pill and nothing else: the PR + // badge now shares the goal row instead. + const cluster = slot[0]?.children[0] as ReactTestInstance | undefined; + expect(cluster?.props.className).toContain('min-w-0'); + expect(cluster?.props.className).toContain('shrink'); + expect(cluster?.findAllByType(SessionContextMetrics)).toHaveLength(1); + expect(cluster?.children).toHaveLength(1); + + // The pill is the flexible part of the cluster: it shrinks into the cap + // with it, so the cluster can never paint past the slot edge. + const metrics = header.findByProps({ testID: 'session-context-metrics' }); + expect(metrics.props.className).toContain('min-w-0'); + expect(metrics.props.className).toContain('shrink'); + }); + + // The cost is the only unbounded string in the cluster: a long total must + // truncate inside the capped pill instead of crossing the gutter. + it('truncates a long cost inside the capped pill', async () => { + const priced = assistantMessage('msg-priced'); + if (priced.info.role !== 'assistant') { + throw new Error('expected an assistant message'); + } + priced.info = { ...priced.info, sessionID: ROOT_ID, cost: 1234.56 }; + const { renderer } = await mountDetails([priced]); + const expected = formatSessionTotalCost(1234.56 * 1_000_000); + expect(expected).not.toBeNull(); + const metrics = renderer.root.findByProps({ testID: 'session-context-metrics' }); + const cost = metrics.find( + node => + Object.is(node.type, 'Text') && + node.children.some(child => typeof child === 'string' && child === expected) + ); + expect(cost.props.numberOfLines).toBe(1); + expect(cost.props.className).toContain('shrink'); + }); +}); + describe('session detail status placement', () => { it.each(['progress', 'info'] as const)( 'centers a %s status without transcript rows', diff --git a/apps/mobile/src/components/agents/use-text-height.mounted.test.tsx b/apps/mobile/src/components/agents/use-text-height.mounted.test.tsx new file mode 100644 index 00000000000..e680c1693a6 --- /dev/null +++ b/apps/mobile/src/components/agents/use-text-height.mounted.test.tsx @@ -0,0 +1,166 @@ +import { createElement } from 'react'; +import { act, TestRenderer } from '@/test/renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useTextHeight } from './use-text-height'; + +// vitest hoists this above the imports; the hook only needs host components. +vi.mock('react-native', () => ({ + Text: 'Text', + TextInput: 'TextInput', + View: 'View', +})); + +// The composer's own geometry: a 44pt one-line floor, a 124pt hard cap, 24pt of +// vertical padding around the text, and a requested 20pt line height. +const OPTIONS = { + fontSize: 16, + fontScale: 1, + lineHeight: 20, + maxHeight: 124, + minHeight: 44, + textContentWidth: 200, + verticalPadding: 24, +}; + +type Published = { height: number; maxHeight: number }; + +let published: Published = { height: 0, maxHeight: 0 }; +let nativeContentHeight: number | null = null; +let cap = OPTIONS.maxHeight; + +function Harness() { + const measure = useTextHeight({ ...OPTIONS, maxHeight: cap, nativeContentHeight }); + published = { height: measure.height, maxHeight: measure.maxHeight }; + return measure.measureElement; +} + +async function mount(capOverride?: number) { + published = { height: 0, maxHeight: 0 }; + nativeContentHeight = null; + cap = capOverride ?? OPTIONS.maxHeight; + const holder: { current?: TestRenderer.ReactTestRenderer } = {}; + await act(async () => { + await Promise.resolve(); + holder.current = TestRenderer.create(createElement(Harness)); + }); + const renderer = holder.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +/** The input's `onContentSizeChange` report, in dp, padding included. */ +function reportNativeContentSize(renderer: TestRenderer.ReactTestRenderer, height: number) { + nativeContentHeight = height; + act(() => { + renderer.update(createElement(Harness)); + }); +} + +/** The hidden mirror's measured text height, before the padding is added. */ +function layoutMirror(renderer: TestRenderer.ReactTestRenderer, textHeight: number) { + const mirror = renderer.root.find( + node => + typeof node.type === 'string' && + (node.type as string) === 'Text' && + typeof node.props.onLayout === 'function' + ); + const layout = mirror.props.onLayout as (event: { + nativeEvent: { layout: { height: number; width: number } }; + }) => void; + act(() => { + layout({ nativeEvent: { layout: { height: textHeight, width: 200 } } }); + }); +} + +describe('useTextHeight pitch snapping', () => { + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + }); + + it('keeps the cap raw until the input reports its content height', async () => { + const renderer = await mount(); + layoutMirror(renderer, 120); + expect(published.maxHeight).toBe(124); + renderer.unmount(); + }); + + it('snaps the cap down to the pitch the input actually renders (Android ignores lineHeight)', async () => { + const renderer = await mount(); + // Roboto's natural line box at 16dp without font padding: 18.67dp per + // line, over the mirror's six 20dp lines. floor((124 - 24) / 18.67) = 5. + reportNativeContentSize(renderer, 24 + 6 * 18.667); + layoutMirror(renderer, 120); + expect(published.maxHeight).toBeCloseTo(24 + 5 * 18.667, 2); + renderer.unmount(); + }); + + it('clamps the published height to the snapped cap, not the raw cap', async () => { + const renderer = await mount(); + reportNativeContentSize(renderer, 24 + 6 * 18.667); + // The mirror honors the requested 20pt line height: six lines measure 120. + layoutMirror(renderer, 120); + expect(published.height).toBeCloseTo(24 + 5 * 18.667, 2); + renderer.unmount(); + }); + + it('keeps a cap that lands on the minimum a whole native line', async () => { + // The smallest remaining-space cap is the caller's 44pt minimum, which is + // a requested 20pt line box rather than a native one. Publishing it as is + // would scroll a two-line draft by 17.33 - a partial line - and clip the + // first visible line again, so the floor is snapped to the same pitch. + const renderer = await mount(44); + reportNativeContentSize(renderer, 24 + 6 * 18.667); + layoutMirror(renderer, 120); + expect(published.maxHeight).toBeCloseTo(24 + 18.667, 2); + expect(published.height).toBeCloseTo(24 + 18.667, 2); + renderer.unmount(); + }); + + it('never snaps the cap past the space the composer has', async () => { + // A reported pitch (24dp) above the requested line box cannot fit a whole + // native line in the 44dp cap: the cap stays the caller's own, inflated by + // neither the snap nor the pitch-aligned floor. + const renderer = await mount(44); + reportNativeContentSize(renderer, 24 + 6 * 24); + layoutMirror(renderer, 120); + expect(published.maxHeight).toBe(44); + expect(published.height).toBe(44); + renderer.unmount(); + }); + + it('leaves an under-cap height untouched by the snap', async () => { + const renderer = await mount(); + reportNativeContentSize(renderer, 24 + 6 * 18.667); + layoutMirror(renderer, 80); + expect(published.height).toBe(104); + renderer.unmount(); + }); + + it('is a no-op where the input renders the requested line height (iOS)', async () => { + const renderer = await mount(); + reportNativeContentSize(renderer, 24 + 6 * 20); + layoutMirror(renderer, 120); + expect(published.maxHeight).toBe(124); + expect(published.height).toBe(124); + renderer.unmount(); + }); + + it('ignores a report whose inferred pitch leaves the plausible band', async () => { + const renderer = await mount(); + reportNativeContentSize(renderer, 24 + 6 * 40); + layoutMirror(renderer, 120); + expect(published.maxHeight).toBe(124); + renderer.unmount(); + }); + + it('ignores a degenerate report that does not clear the padding', async () => { + const renderer = await mount(); + reportNativeContentSize(renderer, 10); + layoutMirror(renderer, 120); + expect(published.maxHeight).toBe(124); + renderer.unmount(); + }); +}); diff --git a/apps/mobile/src/components/agents/use-text-height.tsx b/apps/mobile/src/components/agents/use-text-height.tsx index d04772a6102..8b6513b8238 100644 --- a/apps/mobile/src/components/agents/use-text-height.tsx +++ b/apps/mobile/src/components/agents/use-text-height.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from 'react'; import { type LayoutChangeEvent, Text, type TextStyle, View, type ViewStyle } from 'react-native'; +import { alignComposerInputHeightToLines } from '@/components/agents/chat-composer-input-height'; + type UseTextHeightOptions = { minHeight: number; maxHeight: number; @@ -15,12 +17,37 @@ type UseTextHeightOptions = { * and native scaling applies (the kilo-chat message input path). */ fontScale?: number; + /** + * Content height the caller's real `TextInput` reported for the same text, + * in dp (`onContentSizeChange`), including the input's own vertical padding. + * It is the only faithful measure of the pitch that input lays lines out at: + * the mirror `Text` honors `lineHeight`, while the native input uses the + * platform's own line box. Without it the cap stays raw, which is the + * behavior of every caller that does not report one. + */ + nativeContentHeight?: number | null; initialText?: string; }; +/** Plausible band for the native pitch around the requested line height. */ +const NATIVE_PITCH_MIN_RATIO = 0.6; +const NATIVE_PITCH_MAX_RATIO = 1.4; + /** * Mirrors uncontrolled TextInput contents into a hidden Text node so we can * measure wrapped height without relying on TextInput.onContentSizeChange. + * + * The published cap is snapped down to a whole number of the lines the native + * input actually renders (see `alignComposerInputHeightToLines`). A capped + * multiline input scrolls to the caret by `contentHeight - viewHeight`; if that + * difference is not a whole number of rendered lines, the first visible line is + * painted cut against the input's top edge. Android's `TextInput` lays lines + * out at the font's own line box rather than the requested `lineHeight` — on + * this composer's 16dp Roboto at 420dpi the real input reports 49px per line, + * 18.67dp against a requested 20dp — so the snap uses the pitch measured from + * the real input's reported content height over the mirror's line count, not + * the requested line height. The floor is snapped to that same pitch, so a cap + * that lands on the minimum stays a whole number of native lines too. */ export function useTextHeight({ minHeight, @@ -30,6 +57,7 @@ export function useTextHeight({ fontSize, lineHeight, fontScale, + nativeContentHeight, initialText = '', }: UseTextHeightOptions) { const [text, setMeasuredText] = useState(initialText); @@ -47,6 +75,45 @@ export function useTextHeight({ // Disable native scaling only when the caller pre-scaled, so the scale is // applied exactly once instead of twice. const maxFontSizeMultiplier = fontScale == null ? undefined : 1; + // The mirror and the native input wrap the same text at the same width into + // the same number of lines, so the input's rendered pitch is its reported + // content height (minus the padding it carries) over the mirror's line + // count. The count grows with the draft, so the estimate sharpens exactly + // where it matters: the cap below only bites once the content is tall enough + // to scroll, and by then the report is several lines deep. + const mirrorLineCount = Math.max( + 1, + Math.round((contentHeight - verticalPadding) / scaledLineHeight) + ); + const nativePitch = + nativeContentHeight != null && nativeContentHeight > verticalPadding + ? (nativeContentHeight - verticalPadding) / mirrorLineCount + : null; + // `minHeight` is the caller's one-line floor in the requested line box + // (`scaledLineHeight + padding`), while the snap floors to the pitch the + // native input really renders. A cap that lands on the minimum would publish + // that unaligned minimum and clip the first visible line again, so the floor + // is re-expressed in whole native lines. It is clamped to the cap, which + // keeps the snapped height from ever exceeding the space the composer has. + const alignedMinHeight = + nativePitch !== null + ? Math.min( + verticalPadding + + Math.max(1, Math.floor((minHeight - verticalPadding) / nativePitch)) * nativePitch, + maxHeight + ) + : minHeight; + const effectiveMaxHeight = + nativePitch !== null && + nativePitch >= scaledLineHeight * NATIVE_PITCH_MIN_RATIO && + nativePitch <= scaledLineHeight * NATIVE_PITCH_MAX_RATIO + ? alignComposerInputHeightToLines({ + height: maxHeight, + lineHeight: nativePitch, + verticalPadding, + minHeight: alignedMinHeight, + }) + : maxHeight; function handleMeasureLayout(event: LayoutChangeEvent) { const textHeight = event.nativeEvent.layout.height; @@ -55,9 +122,9 @@ export function useTextHeight({ } useEffect(() => { - const nextHeight = Math.min(Math.max(contentHeight, minHeight), maxHeight); + const nextHeight = Math.min(Math.max(contentHeight, minHeight), effectiveMaxHeight); setHeight(current => (current === nextHeight ? current : nextHeight)); - }, [contentHeight, minHeight, maxHeight]); + }, [contentHeight, minHeight, effectiveMaxHeight]); function setText(nextText: string) { setMeasuredText(nextText); @@ -91,7 +158,7 @@ export function useTextHeight({ ) : null; - return { height, measureElement, reset, setText }; + return { height, maxHeight: effectiveMaxHeight, measureElement, reset, setText }; } const hiddenContainer: ViewStyle = { diff --git a/apps/mobile/src/components/kilo-chat/message-input-content.tsx b/apps/mobile/src/components/kilo-chat/message-input-content.tsx index e272587a04a..033a592d366 100644 --- a/apps/mobile/src/components/kilo-chat/message-input-content.tsx +++ b/apps/mobile/src/components/kilo-chat/message-input-content.tsx @@ -283,7 +283,7 @@ export function MessageInputContent({ sendDisabled={sendDisabled} showAttachmentButton={showAttachmentButton} showCounter={showCounter} - shouldScroll={resolveMessageInputShouldScroll(inputMeasure.height)} + shouldScroll={resolveMessageInputShouldScroll(inputMeasure.height, inputMeasure.maxHeight)} typingMembers={typingMembers} voiceInputActive={voiceInput.isActive} voiceInputAvailable={voiceInput.available} diff --git a/apps/mobile/src/components/kilo-chat/message-input-layout.ts b/apps/mobile/src/components/kilo-chat/message-input-layout.ts index 4e6f2964b8d..10fca340999 100644 --- a/apps/mobile/src/components/kilo-chat/message-input-layout.ts +++ b/apps/mobile/src/components/kilo-chat/message-input-layout.ts @@ -33,8 +33,11 @@ export const messageInputKeyboardProps = { submitBehavior: 'newline', } satisfies Pick; -export function resolveMessageInputShouldScroll(inputHeight: number): boolean { - return inputHeight >= MESSAGE_INPUT_MAX_HEIGHT; +export function resolveMessageInputShouldScroll( + inputHeight: number, + maxHeight: number = MESSAGE_INPUT_MAX_HEIGHT +): boolean { + return inputHeight >= maxHeight; } export function resolveMessageInputHeight(contentHeight: number): number { diff --git a/apps/mobile/src/components/screen-header.mounted.test.tsx b/apps/mobile/src/components/screen-header.mounted.test.tsx index 40426ea0306..16337909fb7 100644 --- a/apps/mobile/src/components/screen-header.mounted.test.tsx +++ b/apps/mobile/src/components/screen-header.mounted.test.tsx @@ -381,6 +381,18 @@ describe('ScreenHeader mounted', () => { } }); + it('paints the large route title in the foreground token, never a muted one', () => { + // Explorer finding 5 (profile, f2181ae79) reported the Profile title muted + // gray while every other heading is white. A pixel measure of the capture + // refuted it: the large title is text-foreground, the same white as the + // Kilo Pass row. Pin the token so the top of the hierarchy cannot silently + // drop to text-muted-foreground. + const renderer = renderHeader({ title: 'Profile', size: 'large' }); + const title = renderer.root.findByProps({ accessibilityRole: 'header' }); + expect(title.props.className).toContain('text-foreground'); + expect(title.props.className).not.toContain('text-muted-foreground'); + }); + it('returns to the previous screen by default', () => { const renderer = renderHeader({ title: 'Sessions' }); act(() => { diff --git a/patches/@expo+router-server@57.0.9.patch b/patches/@expo+router-server@57.0.9.patch new file mode 100644 index 00000000000..9e310385f96 --- /dev/null +++ b/patches/@expo+router-server@57.0.9.patch @@ -0,0 +1,23 @@ +diff --git a/build/typed-routes/generate.js b/build/typed-routes/generate.js +index 8c0627238cd0139ba4345673b414ca95e7404f34..30ffd7cc7dca51080e259b88d03708e3b40f3f19 100644 +--- a/build/typed-routes/generate.js ++++ b/build/typed-routes/generate.js +@@ -13,7 +13,17 @@ function getTypedRoutesDeclarationFile(ctx, { partialTypedGroups = false, testIg + let routeNode = null; + try { + routeNode = (0, routing_1.getRoutes)(ctx, { +- ignore: [/_layout\.[tj]sx?$/], // Skip layout files ++ // START FORK ++ // Colocated test files must not become routes here. Expo Router's ++ // require.context matches every `.tsx` under the app root, so a ++ // `*.test.tsx` next to a route registers as a route — mirroring the ++ // Metro blockList in metro.config.js. Worse, a `_layout.*.test.tsx` ++ // is detected as the directory's layout, and groupRouteNodes below ++ // skips non-route nodes together with their children, dropping the ++ // whole subtree (e.g. every `(tabs)` route) from the generated ++ // `Href` union. ++ ignore: [/_layout\.[tj]sx?$/, /\.test\.[tj]sx?$/], // Skip layout files and colocated tests ++ // END FORK + platformRoutes: false, // We don't need to generate platform specific routes + notFound: false, // We don't need +not-found routes either + ignoreEntryPoints: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47cd4b5eba6..1cc6d0fe098 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,6 +170,7 @@ overrides: packageExtensionsChecksum: sha256-1pgKZxx87NNMe1poF5N5u5kZB/qlEyILBQPxofM1shE= patchedDependencies: + '@expo/router-server@57.0.9': 4edbd2ba8a127dd3b5782bf0cff9696668c38d473e10e21ea00279c989660da1 decode-uri-component@0.5.0: 44dc7ee3ec754d20fab8b07d7efd2442940d872ab7e8c3b61386a3d344fa3ac8 expo-dev-launcher@57.0.19: eb0bf1f0bd20f73906c3d8c5b8b60ec09d57b15317dbf94627f91d1edc2d8657 expo-router@57.0.20: cb9f4edbfee501a4ddee6ca351900b2c18be660bc2e956687352236683980153 @@ -22094,7 +22095,7 @@ snapshots: '@expo/plist': 0.8.1 '@expo/prebuild-config': 57.0.15(typescript@6.0.3) '@expo/require-utils': 57.0.5(typescript@6.0.3) - '@expo/router-server': 57.0.9(@expo/metro-runtime@57.0.15)(expo-constants@57.0.17)(expo-font@57.0.3)(expo-router@57.0.20)(expo-server@57.0.3)(expo@57.0.21)(react-dom@19.2.6(react@19.2.3))(react@19.2.3) + '@expo/router-server': 57.0.9(patch_hash=4edbd2ba8a127dd3b5782bf0cff9696668c38d473e10e21ea00279c989660da1)(@expo/metro-runtime@57.0.15)(expo-constants@57.0.17)(expo-font@57.0.3)(expo-router@57.0.20)(expo-server@57.0.3)(expo@57.0.21)(react-dom@19.2.6(react@19.2.3))(react@19.2.3) '@expo/schema-utils': 57.0.2 '@expo/spawn-async': 1.8.0 '@expo/ws-tunnel': 2.0.0(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) @@ -22171,7 +22172,7 @@ snapshots: '@expo/plist': 0.8.1 '@expo/prebuild-config': 57.0.15(typescript@5.9.3) '@expo/require-utils': 57.0.5(typescript@5.9.3) - '@expo/router-server': 57.0.9(@expo/metro-runtime@57.0.15)(expo-constants@57.0.17)(expo-font@57.0.3)(expo-router@57.0.20)(expo-server@57.0.3)(expo@57.0.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@expo/router-server': 57.0.9(patch_hash=4edbd2ba8a127dd3b5782bf0cff9696668c38d473e10e21ea00279c989660da1)(@expo/metro-runtime@57.0.15)(expo-constants@57.0.17)(expo-font@57.0.3)(expo-router@57.0.20)(expo-server@57.0.3)(expo@57.0.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@expo/schema-utils': 57.0.2 '@expo/spawn-async': 1.8.0 '@expo/ws-tunnel': 2.0.0(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) @@ -22712,7 +22713,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/router-server@57.0.9(@expo/metro-runtime@57.0.15)(expo-constants@57.0.17)(expo-font@57.0.3)(expo-router@57.0.20)(expo-server@57.0.3)(expo@57.0.21)(react-dom@19.2.6(react@19.2.3))(react@19.2.3)': + '@expo/router-server@57.0.9(patch_hash=4edbd2ba8a127dd3b5782bf0cff9696668c38d473e10e21ea00279c989660da1)(@expo/metro-runtime@57.0.15)(expo-constants@57.0.17)(expo-font@57.0.3)(expo-router@57.0.20)(expo-server@57.0.3)(expo@57.0.21)(react-dom@19.2.6(react@19.2.3))(react@19.2.3)': dependencies: debug: 4.4.3 expo: 57.0.21(@babel/core@7.29.7)(@expo/metro-runtime@57.0.15)(bufferutil@4.1.0)(expo-router@57.0.20)(expo-widgets@57.0.18)(react-dom@19.2.6(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(react-native@0.86.3(patch_hash=80d299ea0bcfd0cff3f7749611f42ee55953ed7e7456552820693b83a0853ebc)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(patch_hash=80d299ea0bcfd0cff3f7749611f42ee55953ed7e7456552820693b83a0853ebc)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) @@ -22727,7 +22728,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/router-server@57.0.9(@expo/metro-runtime@57.0.15)(expo-constants@57.0.17)(expo-font@57.0.3)(expo-router@57.0.20)(expo-server@57.0.3)(expo@57.0.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@expo/router-server@57.0.9(patch_hash=4edbd2ba8a127dd3b5782bf0cff9696668c38d473e10e21ea00279c989660da1)(@expo/metro-runtime@57.0.15)(expo-constants@57.0.17)(expo-font@57.0.3)(expo-router@57.0.20)(expo-server@57.0.3)(expo@57.0.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: debug: 4.4.3 expo: 57.0.21(@babel/core@7.29.7)(@expo/metro-runtime@57.0.15)(bufferutil@4.1.0)(expo-router@57.0.20)(expo-widgets@57.0.18)(react-dom@19.2.6(react@19.2.6))(react-native-worklets@0.10.1(@babel/core@7.29.7)(react-native@0.86.3(patch_hash=80d299ea0bcfd0cff3f7749611f42ee55953ed7e7456552820693b83a0853ebc)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.86.3(patch_hash=80d299ea0bcfd0cff3f7749611f42ee55953ed7e7456552820693b83a0853ebc)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@6.0.6) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 97febd4f07a..1abe18a3186 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -221,6 +221,14 @@ patchedDependencies: # widgets — Live Activity state query (getInfo), areLiveActivitiesEnabled, # includeEnded instance reads, factory-registered push-to-start URL. expo-router@57.0.20: patches/expo-router@57.0.20.patch + # Typed-routes generation must skip colocated tests, exactly like the Metro + # blockList in apps/mobile/metro.config.js already does for the runtime + # bundle. A `*.test.tsx` next to a route registers as a route, and a + # `_layout.*.test.tsx` (src/app/(app)/(tabs)) registers as that group's + # layout; the generator then drops the group's entire subtree, so every + # `(tabs)` route vanishes from the generated `Href` union and typecheck + # rejects valid navigations. + '@expo/router-server@57.0.9': patches/@expo+router-server@57.0.9.patch expo-server-sdk: patches/expo-server-sdk.patch # Null-guard the headless app loader in TaskService.executeTask. Without it a # data-only FCM wake during cold start dereferences a null loader and fatally