Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { describe, expect, it } from 'vitest';

import {
alignComposerInputHeightToLines,
COMPOSER_CHROME_HEIGHT,
COMPOSER_INPUT_MAX_HEIGHT,
COMPOSER_INPUT_PADDING_HORIZONTAL,
Expand Down Expand Up @@ -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,
Expand Down
32 changes: 32 additions & 0 deletions apps/mobile/src/components/agents/chat-composer-input-height.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ function makeProps(overrides: Partial<RenderProps> = {}) {
onInputBlur: () => undefined,
onInputFocus: () => undefined,
onInputLayout: () => undefined,
onInputContentSizeChange: () => undefined,
onInsertNewline: () => undefined,
onSelectionChange: () => undefined,
onStop: () => undefined,
Expand Down
10 changes: 10 additions & 0 deletions apps/mobile/src/components/agents/chat-composer-input-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -81,6 +87,7 @@ export function ChatComposerInputRow({
onChangeText,
onInputBlur,
onInputFocus,
onInputContentSizeChange,
onInputLayout,
onInsertNewline,
onSelectionChange,
Expand Down Expand Up @@ -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}
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/components/agents/chat-composer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
18 changes: 15 additions & 3 deletions apps/mobile/src/components/agents/chat-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<number | null>(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.
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -1342,6 +1353,7 @@ export function ChatComposer({
setIsFocused(true);
}}
onInputLayout={handleInputLayout}
onInputContentSizeChange={setInputContentHeight}
onInsertNewline={handleInsertNewline}
onSelectionChange={handleSelectionChange}
onStop={handleStop}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/components/agents/new-session-prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
58 changes: 58 additions & 0 deletions apps/mobile/src/components/agents/session-detail-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading