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
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 @@ -986,6 +986,19 @@ describe('NewSessionConfigureForm', () => {
expect(findElementByType(remote, 'NewSessionCloudCreateError')).toBeNull();
});

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

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

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

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

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

const body = (
Expand All @@ -155,7 +159,7 @@ export function NewSessionConfigureForm({
onLayout={event => {
// The one layout feeds both consumers: the form frame height sets the
// prompt's input floor, and the hook's viewport height drives the reveal.
handleScrollViewportLayout(event);
handleScrollFrameLayout(event);
composerReveal.onViewportLayout(event.nativeEvent.layout.height);
}}
onScroll={event => {
Expand All @@ -172,6 +176,8 @@ export function NewSessionConfigureForm({
y: event.nativeEvent.layout.y,
height: event.nativeEvent.layout.height,
});
const nextTop = Math.max(Math.round(event.nativeEvent.layout.y), 0);
setComposerTop(current => (current === nextTop ? current : nextTop));
}}
>
<NewSessionPrompt
Expand Down Expand Up @@ -200,7 +206,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