Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 3 additions & 1 deletion apps/mobile/plugins/branded-splash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ describe('shared branded splash', () => {
// fold a developer's generated, gitignored `android/` tree into the result —
// its `colors.xml` is absent in CI — and merge colors this test does not own
// into the mod results, so the run would no longer describe only this
// plugin's output.
// plugin's output. The colors array is asserted by containment for the same
// reason: the project's own other theme colors (iconBackground, colorPrimary,
// …) can ride along without failing this case.
const { root } = createAndroidProject();
const config: ExportedConfig = withBrandedSplash(
{ name: 'Kilo', slug: 'kilo-app', _internal: { projectRoot } },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { createElement } from 'react';
import { describe, expect, it, vi } from 'vitest';

import { act, TestRenderer } from '@/test/renderer';

import '@/i18n';

import { NewSessionConfigureForm } from './new-session-configure-form';

vi.mock('react-native', () => ({
I18nManager: { isRTL: false },
Keyboard: { addListener: vi.fn(() => ({ remove: vi.fn() })) },
Platform: { OS: 'ios' },
ScrollView: 'ScrollView',
View: 'View',
}));

vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
}));

vi.mock('@/components/kilo-chat/app-aware-keyboard-padding', () => ({
AppAwareKeyboardPaddingView: 'AppAwareKeyboardPaddingView',
}));

// The composer card is the observation point, not the subject: its props carry
// the frame geometry this suite asserts.
vi.mock('@/components/agents/new-session-prompt', () => ({
NewSessionPrompt: 'NewSessionPrompt',
}));

vi.mock('@/components/agents/folder-selector', () => ({ LaunchFolderField: 'LaunchFolderField' }));
vi.mock('@/components/agents/new-session-cloud-create-error', () => ({
NewSessionCloudCreateError: 'NewSessionCloudCreateError',
}));
vi.mock('@/components/agents/new-session-repository-section', () => ({
NewSessionRepositorySection: 'NewSessionRepositorySection',
}));
vi.mock('@/components/agents/new-session-run-target', () => ({
NewSessionRunTarget: 'NewSessionRunTarget',
}));
vi.mock('@/components/agents/new-session-start-button', () => ({
NewSessionStartButton: 'NewSessionStartButton',
}));
vi.mock('@/components/ui/button', () => ({ Button: 'Button' }));
vi.mock('@/components/ui/icons', () => ({ RefreshCw: 'RefreshCw' }));
vi.mock('@/components/ui/segmented-control', () => ({ SegmentedControl: 'SegmentedControl' }));
vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' }));
vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));

type Instance = TestRenderer.ReactTestInstance;
type LayoutHandler = (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;

/** The typed `onLayout` a host carries, so firing it is not an `any` call. */
function layoutHandler(node: Instance | undefined): LayoutHandler | null {
const handler = node?.props.onLayout as LayoutHandler | undefined;
return handler ?? null;
}

function defaultProps() {
return {
attachments: [] as never[],
attachmentMax: 5,
isCreating: false,
isModelsError: false,
isLoadingModels: false,
mode: 'code' as const,
model: 'anthropic/claude-sonnet-4',
variant: 'medium',
modelOptions: [] as never[],
onChangeText: vi.fn(),
onModeChange: vi.fn(),
onModelSelect: vi.fn(),
onAddAttachment: vi.fn(),
onRemoveAttachment: vi.fn(),
onRetryAttachment: vi.fn(),
onMoveAttachment: vi.fn(),
onReorderAttachments: vi.fn(),
onRefetchModels: vi.fn(),
onPrefillAttachments: vi.fn(),
shareId: undefined as string | undefined,
voiceInputSettlerRef: { current: null },
showRunOnSelector: false,
runOnInstance: null,
instanceList: [] as never[],
isLoadingInstances: false,
isFetchingInstances: false,
onRefreshInstances: vi.fn(),
onChangeRunOnInstance: vi.fn(),
showInstanceDisconnectedNote: false,
folderPath: '',
onChangeFolderPath: vi.fn(),
groups: [] as never[],
isRetrying: false,
onChangeRepo: vi.fn(),
onConnectProvider: vi.fn(),
onRefreshRepos: vi.fn(),
repositories: [] as never[],
recents: [] as never[],
selectedRepo: '',
organizationId: undefined as string | undefined,
profile: null,
isProfileLoading: false,
isProfileError: false,
onRetryProfile: vi.fn(),
autoCommit: false,
onAutoCommitChange: vi.fn(),
isSpawningRemote: false,
isStartDisabled: false,
onStartSession: vi.fn(),
};
}

function findAllByType(root: Instance, type: string): Instance[] {
return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type);
}

/** The first host below the ScrollView carrying `onLayout` — the composer wrapper. */
function findComposerWrapper(root: Instance): Instance | undefined {
return root.findAll(
node =>
typeof node.type === 'string' &&
(node.type as string) !== 'ScrollView' &&
typeof node.props.onLayout === 'function'
)[0];
}

describe('NewSessionConfigureForm composer frame geometry', () => {
it('threads the card top gap from the composer wrapper into NewSessionPrompt', async () => {
const holder: { current?: TestRenderer.ReactTestRenderer } = {};
await act(() => {
holder.current = TestRenderer.create(createElement(NewSessionConfigureForm, defaultProps()));
});
const renderer = holder.current;
if (!renderer) {
throw new Error('renderer was not created');
}

const scrollView = findAllByType(renderer.root, 'ScrollView')[0];
const composerWrapper = findComposerWrapper(renderer.root);
expect(scrollView).toBeDefined();
expect(composerWrapper).toBeDefined();

await act(() => {
// The lifted frame the keyboard leaves.
layoutHandler(scrollView)?.({ nativeEvent: { layout: { y: 0, height: 380 } } });
// The content container's `pt-4` inset puts the card 16pt into the frame.
layoutHandler(composerWrapper)?.({ nativeEvent: { layout: { y: 16, height: 420 } } });
});

const prompt = findAllByType(renderer.root, 'NewSessionPrompt')[0];
expect(prompt).toBeDefined();
expect(prompt?.props.frameHeight).toBe(380);
// Regression: the prompt's own onLayout reads 0 against the padding-free
// wrapper, so the host must pass the wrapper's frame offset instead.
expect(prompt?.props.cardTop).toBe(16);

renderer.unmount();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,19 @@ describe('NewSessionConfigureForm', () => {
expect(findElementByType(remote, 'NewSessionCloudCreateError')).toBeNull();
});

// ── Case 15: the scroll frame is measured and handed to the prompt ──
it('measures the scroll frame and threads it to NewSessionPrompt', async () => {
const { NewSessionConfigureForm } = await import('./new-session-configure-form');

// eslint-disable-next-line new-cap -- plain function call, matching repo test convention
const element = NewSessionConfigureForm(defaultProps()) as Node;

// The ScrollView reports its height so the prompt can yield its floor to it.
expect(findElementByType(element, 'ScrollView')?.onLayout).toEqual(expect.any(Function));
// The mocked useState holds the initial measurement (0) and never setStates.
expect(findElementByType(element, 'NewSessionPrompt')?.frameHeight).toBe(0);
});

// ── Case 16: reveal the composer card's bottom row above the IME ──
it('scrolls the composer card bottom above the keyboard once it opens', async () => {
const { NewSessionConfigureForm } = await import('./new-session-configure-form');
Expand Down
29 changes: 18 additions & 11 deletions apps/mobile/src/components/agents/new-session-configure-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,21 +115,25 @@ export function NewSessionConfigureForm({
// layout and never re-anchors, so the keyboard must be dismissed before
// the sheet opens.)
const { bottom } = useSafeAreaInsets();
// The scroll frame's own height, reported by the ScrollView below. It already
// shrinks with the keyboard because `AppAwareKeyboardPaddingView` pads this
// parent; the prompt yields its minimum height to it so the whole composer
// card renders above the bottom system bar.
const [frameHeight, setFrameHeight] = useState(0);
// The composer card's top offset inside the scroll content, reported by the
// wrapper below and threaded to the prompt. The prompt's own `onLayout` reads
// `0` against that padding-free wrapper, dropping the content container's
// `pt-4` gap and overstating the room the frame leaves for the input.
const [composerTop, setComposerTop] = useState(0);
const isRemote = runOnInstance !== null;
// The frame this form scrolls in: the height left once the navigation-bar
// inset and the keyboard-lift padding are taken out. `NewSessionPrompt`
// measures its input's min-height floor against this frame, so the input
// gives lines up to the keyboard and takes them back when it leaves — the
// window-based floor could not, because the window never resizes for the IME.
const [promptViewportHeight, setPromptViewportHeight] = useState(0);
const isStarting = isRemote ? isSpawningRemote : isCreating;
const runOnNote =
runOnInlineNote ??
(showInstanceDisconnectedNote ? remoteSpawnInstanceDisconnectedNote() : null);

function handleScrollViewportLayout(event: LayoutChangeEvent) {
const nextHeight = Math.round(event.nativeEvent.layout.height);
setPromptViewportHeight(current => (current === nextHeight ? current : nextHeight));
function handleScrollFrameLayout(event: LayoutChangeEvent) {
const next = Math.max(Math.round(event.nativeEvent.layout.height), 0);
setFrameHeight(current => (current === next ? current : next));
}

const body = (
Expand All @@ -143,7 +147,7 @@ export function NewSessionConfigureForm({
onLayout={event => {
// The one layout feeds both consumers: the form frame height sets the
// prompt's input floor, and the hook's viewport height drives the reveal.
handleScrollViewportLayout(event);
handleScrollFrameLayout(event);
composerReveal.onViewportLayout(event.nativeEvent.layout.height);
}}
onScroll={event => {
Expand All @@ -160,6 +164,8 @@ export function NewSessionConfigureForm({
y: event.nativeEvent.layout.y,
height: event.nativeEvent.layout.height,
});
const nextTop = Math.max(Math.round(event.nativeEvent.layout.y), 0);
setComposerTop(current => (current === nextTop ? current : nextTop));
}}
>
<NewSessionPrompt
Expand Down Expand Up @@ -188,7 +194,8 @@ export function NewSessionConfigureForm({
shareId={shareId}
voiceInputSettlerRef={voiceInputSettlerRef}
initialPrompt={initialPrompt}
promptViewportHeight={promptViewportHeight}
frameHeight={frameHeight}
cardTop={composerTop}
onStartSession={isStartDisabled ? undefined : onStartSession}
isCloneEntry={isCloneEntry}
/>
Expand Down
136 changes: 136 additions & 0 deletions apps/mobile/src/components/agents/new-session-prompt-fit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest';

import { resolveNewSessionPromptMinHeight } from './new-session-prompt-fit';

const LINE_HEIGHT = 24;
const PREFERRED_LINES = 3;
const PREFERRED_MIN_HEIGHT = LINE_HEIGHT * PREFERRED_LINES + 16;

const PREFERRED = {
preferredMinHeight: PREFERRED_MIN_HEIGHT,
lineHeight: LINE_HEIGHT,
preferredLines: PREFERRED_LINES,
} as const;

/**
* The explorer finding's geometry, in points: the card sat 439px in a 410px
* frame — its bottom 61px (≈27dp) behind the system bar — with the input at
* its 3-line floor. The card's own chrome is ~103dp and it starts ~14dp below
* the frame top.
*/
const EXPLORER = {
frameHeight: 178,
cardTop: 14,
cardChromeHeight: 103,
} as const;

describe('resolveNewSessionPromptMinHeight', () => {
it('yields whole lines on the explorer frame so the whole card fits', () => {
// Precondition: the preferred floor cannot fit, so the defect is present.
expect(EXPLORER.cardTop + EXPLORER.cardChromeHeight + PREFERRED_MIN_HEIGHT).toBeGreaterThan(
EXPLORER.frameHeight
);

const fitted = resolveNewSessionPromptMinHeight({ ...PREFERRED, ...EXPLORER });

// 178 - 14 - 103 = 61 available, padding 16 -> one 24pt line: 40.
expect(fitted).toBe(40);
expect(fitted).toBeLessThan(PREFERRED_MIN_HEIGHT);
// The card, at the fitted floor, sits entirely inside the frame.
expect(EXPLORER.cardTop + EXPLORER.cardChromeHeight + fitted).toBeLessThanOrEqual(
EXPLORER.frameHeight
);
});

it('yields one line less once the card top gap crosses a whole-line boundary', () => {
// 195 - 0 - 103 = 92 available: three lines fit, so this frame keeps the
// preferred floor only because the card top gap is zero. The card really
// starts ~14pt below the frame top, which leaves 78 -> two lines. Reading
// the gap as 0 overstates the room by exactly that line.
const frameHeight = 195;
const withoutGap = resolveNewSessionPromptMinHeight({
...PREFERRED,
...EXPLORER,
frameHeight,
cardTop: 0,
});
const withGap = resolveNewSessionPromptMinHeight({ ...PREFERRED, ...EXPLORER, frameHeight });

expect(withoutGap).toBe(PREFERRED_MIN_HEIGHT);
expect(withGap).toBe(PREFERRED_MIN_HEIGHT - LINE_HEIGHT);
});

it('always returns a whole number of lines plus the preferred padding', () => {
const padding = PREFERRED_MIN_HEIGHT - LINE_HEIGHT * PREFERRED_LINES;

for (const frameHeight of [120, 150, 178, 205, 260, 400]) {
const fitted = resolveNewSessionPromptMinHeight({ ...PREFERRED, ...EXPLORER, frameHeight });
expect((fitted - padding) % LINE_HEIGHT).toBe(0);
expect(fitted).toBeGreaterThanOrEqual(LINE_HEIGHT + padding);
}
});

it('keeps the 3-line start when the frame has room', () => {
expect(resolveNewSessionPromptMinHeight({ ...PREFERRED, ...EXPLORER, frameHeight: 400 })).toBe(
PREFERRED_MIN_HEIGHT
);
});

it('never drops below one whole line, even when the frame cannot fit one', () => {
expect(resolveNewSessionPromptMinHeight({ ...PREFERRED, ...EXPLORER, frameHeight: 100 })).toBe(
LINE_HEIGHT + (PREFERRED_MIN_HEIGHT - LINE_HEIGHT * PREFERRED_LINES)
);
});

it.each([
{ name: 'an unmeasured frame', overrides: { frameHeight: 0 } },
{ name: 'a non-finite frame', overrides: { frameHeight: Number.NaN } },
{ name: 'an unmeasured chrome', overrides: { cardChromeHeight: 0 } },
{ name: 'a non-positive line height', overrides: { lineHeight: 0 } },
{ name: 'no preferred lines', overrides: { preferredLines: 0 } },
{ name: 'a negative card top', overrides: { cardTop: -1 } },
])('returns the preferred floor for $name', ({ overrides }) => {
expect(resolveNewSessionPromptMinHeight({ ...PREFERRED, ...EXPLORER, ...overrides })).toBe(
PREFERRED_MIN_HEIGHT
);
});

it('returns the given floor unchanged when it is itself non-positive', () => {
expect(
resolveNewSessionPromptMinHeight({ ...PREFERRED, ...EXPLORER, preferredMinHeight: 0 })
).toBe(0);
});

it('lowers the floor as the frame shrinks and never exceeds the preferred floor', () => {
const roomy = resolveNewSessionPromptMinHeight({ ...PREFERRED, ...EXPLORER, frameHeight: 260 });
const tight = resolveNewSessionPromptMinHeight({ ...PREFERRED, ...EXPLORER, frameHeight: 178 });
const cramped = resolveNewSessionPromptMinHeight({
...PREFERRED,
...EXPLORER,
frameHeight: 120,
});

expect(roomy).toBeGreaterThanOrEqual(tight);
expect(tight).toBeGreaterThanOrEqual(cramped);
expect(roomy).toBeLessThanOrEqual(PREFERRED_MIN_HEIGHT);
});

it('a taller error row only lowers the fitted floor', () => {
// A frame with room for the preferred floor, so adding the error row's
// height must cross a whole-line boundary instead of clamping to the
// one-line floor (where plain and error-row results would be equal).
const frameHeight = 225;
const plain = resolveNewSessionPromptMinHeight({ ...PREFERRED, ...EXPLORER, frameHeight });
const withErrorRow = resolveNewSessionPromptMinHeight({
...PREFERRED,
...EXPLORER,
frameHeight,
cardChromeHeight: EXPLORER.cardChromeHeight + 40,
});

expect(plain).toBe(PREFERRED_MIN_HEIGHT);
expect(withErrorRow).toBeLessThan(plain);
expect(withErrorRow).toBe(PREFERRED_MIN_HEIGHT - LINE_HEIGHT);
expect(withErrorRow).toBeGreaterThanOrEqual(LINE_HEIGHT + 16);
});
});
Loading
Loading