From 6ceeb853fe388d6017d204dfab9d9c20f2eb763d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 23 Sep 2026 09:31:46 +0200 Subject: [PATCH 1/3] fix(mobile): stop the composer clipping the first draft line and cap the session header https://github.com/Kilo-Org/cloud/pull/6522 --- apps/mobile/plugins/branded-splash.test.ts | 9 +- .../chat-composer-attachment-send.test.ts | 1 + .../agents/chat-composer-input-height.test.ts | 98 ++++++++++++ .../agents/chat-composer-input-height.ts | 32 ++++ .../chat-composer-input-row.mounted.test.tsx | 1 + .../agents/chat-composer-input-row.tsx | 10 ++ .../components/agents/chat-composer.test.ts | 1 + .../src/components/agents/chat-composer.tsx | 18 ++- .../new-session-prompt-initial-prompt.test.ts | 1 + .../components/agents/new-session-prompt.tsx | 2 +- .../agents/session-context-metrics.tsx | 4 +- .../agents/session-detail-content.test.ts | 60 ++++++++ .../agents/use-text-height.mounted.test.tsx | 139 ++++++++++++++++++ .../src/components/agents/use-text-height.tsx | 58 +++++++- .../kilo-chat/message-input-content.tsx | 2 +- .../kilo-chat/message-input-layout.ts | 7 +- .../components/screen-header.mounted.test.tsx | 12 ++ patches/@expo+router-server@57.0.9.patch | 23 +++ pnpm-lock.yaml | 10 +- pnpm-workspace.yaml | 8 + 20 files changed, 476 insertions(+), 20 deletions(-) create mode 100644 apps/mobile/src/components/agents/use-text-height.mounted.test.tsx create mode 100644 patches/@expo+router-server@57.0.9.patch diff --git a/apps/mobile/plugins/branded-splash.test.ts b/apps/mobile/plugins/branded-splash.test.ts index 22838dcf4d6..f43fc16f678 100644 --- a/apps/mobile/plugins/branded-splash.test.ts +++ b/apps/mobile/plugins/branded-splash.test.ts @@ -186,10 +186,11 @@ describe('shared branded splash', () => { // `resources` when the file is absent, so the project's own gitignored // `android/app/src/main/res/values/colors.xml` (absent in CI, present in a // worktree that prebuilt) cannot add its icon, notification or app-background - // entries to the array. Assert the splash color this plugin owns is present - // by containment, not by pinning the whole array or its exact length, the - // same way the styles assertion below pins its theme: this case speaks only - // for the entry this plugin writes. + // entries to the array. Assert the splash entry this plugin owns is present, + // like the styles assertion below, instead of that the file holds nothing + // else: containment, not the whole array and not its exact length, so a + // prebuild's other colors (iconBackground, colorPrimary, …) surviving here + // cannot fail the case. expect(evaluated._internal?.modResults?.android?.colors).toMatchObject({ resources: { color: expect.arrayContaining([ 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..f2fe7e86d83 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 font-scaled line height + * 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 951d818077c..0464bbe8bd9 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 572d467b6cb..7d04d9aeb54 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 b8bcc81ef14..7f792c11ffa 100644 --- a/apps/mobile/src/components/agents/new-session-prompt.tsx +++ b/apps/mobile/src/components/agents/new-session-prompt.tsx @@ -471,7 +471,7 @@ export function NewSessionPrompt({ onChangeText={handlePromptChange} onSelectionChange={handlePromptSelectionChange} onLayout={handlePromptInputLayout} - scrollEnabled={promptMeasure.height >= promptMaxHeight} + scrollEnabled={promptMeasure.height >= promptMeasure.maxHeight} editable={control.inputEditable} maxLength={PROMPT_INPUT_MAX_CHARS} accessibilityState={{ disabled: control.inputAccessibilityDisabled }} 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 3d01dcb6078..8c77df1e277 100644 --- a/apps/mobile/src/components/agents/session-detail-content.test.ts +++ b/apps/mobile/src/components/agents/session-detail-content.test.ts @@ -46,7 +46,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 { SessionContextSheet } from '@/components/agents/session-context-sheet'; +import { formatSessionTotalCost } from '@/components/agents/session-list-helpers'; +import { SessionPrBadge } from '@/components/agents/session-pr-badge'; 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'; @@ -950,6 +953,63 @@ 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 PR badge and the context pill, nothing + // else. + 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(SessionPrBadge)).toHaveLength(1); + expect(cluster?.findAllByType(SessionContextMetrics)).toHaveLength(1); + expect(cluster?.children).toHaveLength(2); + + // 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..d7536a45b9e --- /dev/null +++ b/apps/mobile/src/components/agents/use-text-height.mounted.test.tsx @@ -0,0 +1,139 @@ +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; + +function Harness() { + const measure = useTextHeight({ ...OPTIONS, nativeContentHeight }); + published = { height: measure.height, maxHeight: measure.maxHeight }; + return measure.measureElement; +} + +async function mount() { + published = { height: 0, maxHeight: 0 }; + nativeContentHeight = null; + 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('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..69d60a8972e 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,36 @@ 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. */ export function useTextHeight({ minHeight, @@ -30,6 +56,7 @@ export function useTextHeight({ fontSize, lineHeight, fontScale, + nativeContentHeight, initialText = '', }: UseTextHeightOptions) { const [text, setMeasuredText] = useState(initialText); @@ -47,6 +74,31 @@ 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; + const effectiveMaxHeight = + nativePitch !== null && + nativePitch >= scaledLineHeight * NATIVE_PITCH_MIN_RATIO && + nativePitch <= scaledLineHeight * NATIVE_PITCH_MAX_RATIO + ? alignComposerInputHeightToLines({ + height: maxHeight, + lineHeight: nativePitch, + verticalPadding, + minHeight, + }) + : maxHeight; function handleMeasureLayout(event: LayoutChangeEvent) { const textHeight = event.nativeEvent.layout.height; @@ -55,9 +107,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 +143,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 9780ee5da0f..f8d1fa44b14 100644 --- a/apps/mobile/src/components/screen-header.mounted.test.tsx +++ b/apps/mobile/src/components/screen-header.mounted.test.tsx @@ -306,6 +306,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 9f01824adb1..66695e04fe6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,6 +169,7 @@ overrides: packageExtensionsChecksum: sha256-1pgKZxx87NNMe1poF5N5u5kZB/qlEyILBQPxofM1shE= patchedDependencies: + '@expo/router-server@57.0.9': 4edbd2ba8a127dd3b5782bf0cff9696668c38d473e10e21ea00279c989660da1 decode-uri-component@0.5.0: 44dc7ee3ec754d20fab8b07d7efd2442940d872ab7e8c3b61386a3d344fa3ac8 expo-router@57.0.20: cb9f4edbfee501a4ddee6ca351900b2c18be660bc2e956687352236683980153 expo-server-sdk: 7850520582b5b394397b35d1ea195192fe78589d8a6a748fe15177b818c4ed0b @@ -17377,7 +17378,6 @@ packages: react-native-render-html@6.3.4: resolution: {integrity: sha512-H2jSMzZjidE+Wo3qCWPUMU1nm98Vs2SGCvQCz/i6xf0P3Y9uVtG/b0sDbG/cYFir2mSYBYCIlS1Dv0WC1LjYig==} - deprecated: This package has been replaced with @native-html/render peerDependencies: react: '*' react-native: '*' @@ -22257,7 +22257,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)) @@ -22334,7 +22334,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)) @@ -22875,7 +22875,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) @@ -22890,7 +22890,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 4a1d2b5f90b..5e9c0ede29c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -219,6 +219,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 From 2170f4f0202f51e41579a474b74eadbaf067e713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 25 Sep 2026 15:15:54 +0200 Subject: [PATCH 2/3] fix(mobile): snap the composer cap floor to the native line pitch The cap snap floored at the caller's one-line minimum (requested line-height + padding), but the snap measures the pitch the native input really renders (18.67dp against a requested 20dp on Android). A remaining-space cap that landed on that minimum therefore published the unaligned minimum, so a two-line draft still scrolled by 17.33 - a partial line - and clipped the first visible line, exactly the defect the snap exists to fix. Express the floor in whole native lines and clamp it to the cap, so the snapped height is always a whole number of native lines and never exceeds the space the composer has. Also correct the helper's doc comment: the caller passes the measured pitch, not the requested font-scaled line height. --- .../agents/chat-composer-input-height.ts | 4 +-- .../agents/use-text-height.mounted.test.tsx | 31 +++++++++++++++++-- .../src/components/agents/use-text-height.tsx | 19 ++++++++++-- 3 files changed, 48 insertions(+), 6 deletions(-) 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 f2fe7e86d83..fdf2887fbb5 100644 --- a/apps/mobile/src/components/agents/chat-composer-input-height.ts +++ b/apps/mobile/src/components/agents/chat-composer-input-height.ts @@ -295,8 +295,8 @@ export function resolveComposerMaxHeight({ * * 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 font-scaled line height - * and the input's own unscaled vertical padding. + * rather than dividing by zero. The caller passes the measured line pitch and + * the input's own unscaled vertical padding. */ export function alignComposerInputHeightToLines({ height, 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 index d7536a45b9e..e680c1693a6 100644 --- a/apps/mobile/src/components/agents/use-text-height.mounted.test.tsx +++ b/apps/mobile/src/components/agents/use-text-height.mounted.test.tsx @@ -27,16 +27,18 @@ 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, nativeContentHeight }); + const measure = useTextHeight({ ...OPTIONS, maxHeight: cap, nativeContentHeight }); published = { height: measure.height, maxHeight: measure.maxHeight }; return measure.measureElement; } -async function mount() { +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(); @@ -104,6 +106,31 @@ describe('useTextHeight pitch snapping', () => { 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); diff --git a/apps/mobile/src/components/agents/use-text-height.tsx b/apps/mobile/src/components/agents/use-text-height.tsx index 69d60a8972e..8b6513b8238 100644 --- a/apps/mobile/src/components/agents/use-text-height.tsx +++ b/apps/mobile/src/components/agents/use-text-height.tsx @@ -46,7 +46,8 @@ const NATIVE_PITCH_MAX_RATIO = 1.4; * 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 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, @@ -88,6 +89,20 @@ export function useTextHeight({ 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 && @@ -96,7 +111,7 @@ export function useTextHeight({ height: maxHeight, lineHeight: nativePitch, verticalPadding, - minHeight, + minHeight: alignedMinHeight, }) : maxHeight; From e5ccff9ca5456416a77fb7e285dd7fd1ca888b28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 25 Sep 2026 15:20:17 +0200 Subject: [PATCH 3/3] test(mobile): match the header cluster to the merged goal-row layout main moved the PR badge out of the header's right slot onto the goal row (#6562/#6614), so the cluster now holds only the context pill. Assert that, and drop the import the assertion no longer needs. --- .../src/components/agents/session-detail-content.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 91bb0d8e462..22e9da97856 100644 --- a/apps/mobile/src/components/agents/session-detail-content.test.ts +++ b/apps/mobile/src/components/agents/session-detail-content.test.ts @@ -53,7 +53,6 @@ import { SessionContextMetrics } from '@/components/agents/session-context-metri 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 { SessionPrBadge } from '@/components/agents/session-pr-badge'; 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'; @@ -1299,14 +1298,13 @@ describe('session detail header right cluster', () => { 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 PR badge and the context pill, nothing - // else. + // 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(SessionPrBadge)).toHaveLength(1); expect(cluster?.findAllByType(SessionContextMetrics)).toHaveLength(1); - expect(cluster?.children).toHaveLength(2); + 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.