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
@@ -0,0 +1,99 @@
import { type RefObject } from 'react';

import {
type NewSessionRepository,
type RepositoryGroup,
type RepositoryPlatform,
} from '@/components/agents/new-session-repository-state';
import { type CloudCreateFailure } from '@/components/agents/use-new-session-creator';
import { type AgentMode } from '@/components/agents/mode-selector';
import { type EffectiveAgentProfile } from '@/components/agents/use-effective-agent-profile';
import { type ModeOption } from '@/components/agents/mode-normalize';
import {
type AgentAttachment,
type AgentAttachmentCandidate,
type AttachmentMoveDirection,
} from '@/lib/agent-attachments/use-agent-attachment-upload';
import { type ModelOption } from '@/lib/hooks/use-available-models';
import { type SessionModelOption } from '@/lib/hooks/use-session-model-options';
import { type InstancePickerInstance, type ModelPickerSelection } from '@/lib/picker-bridge';

/**
* The New session screen body's contract, extracted beside the component so the
* form file stays under the repo's max-lines cap; the shape is unchanged.
*/
export type NewSessionConfigureFormProps = {
// Prompt / model / attachments (Cloud Agent only).
attachments: AgentAttachment[];
attachmentMax: number;
isCreating: boolean;
isModelsError: boolean;
isLoadingModels: boolean;
mode: AgentMode;
model: string;
variant: string;
modelOptions: (ModelOption | SessionModelOption)[];
onChangeText: (text: string) => void;
onModeChange: (mode: AgentMode) => void;
onModelSelect: (modelId: string, variant: string, pickerSelection?: ModelPickerSelection) => void;
/** Custom mode options shown under the built-ins in the mode picker. */
customOptions?: ModeOption[];
/** Locks the model picker to the pinned agent model (Cloud Agent only). */
modelLocked?: boolean;
/** Agent name shown in the locked model chip's accessibility label. */
modelLockLabel?: string;
onAddAttachment: () => void;
onRemoveAttachment: (id: string) => void;
onRetryAttachment: (id: string) => void;
onMoveAttachment: (id: string, direction: AttachmentMoveDirection) => void;
onReorderAttachments: (fromIndex: number, toIndex: number) => void;
onRefetchModels: () => void;
onPrefillAttachments: (candidates: AgentAttachmentCandidate[]) => Promise<void>;
shareId: string | undefined;
voiceInputSettlerRef: RefObject<(() => Promise<boolean>) | null>;
initialPrompt?: string;
// Run target.
showRunOnSelector: boolean;
runOnInstance: InstancePickerInstance | null;
instanceList: InstancePickerInstance[];
isLoadingInstances: boolean;
isFetchingInstances: boolean;
onRefreshInstances: () => void;
onChangeRunOnInstance: (next: InstancePickerInstance | null) => void;
showInstanceDisconnectedNote: boolean;
// Launch folder (remote CLI only). `""` means the launch directory.
folderPath: string;
onChangeFolderPath: (path: string) => void;
/** Continue-form inline reason shown under "Run on" (e.g. an incapable CLI or a failed clone/import). */
runOnInlineNote?: string | null;
/** True for the Continue clone entry: hides Changes and Environment. */
isCloneEntry?: boolean;
// Repository (Cloud Agent only).
groups: RepositoryGroup[];
isRetrying: boolean;
onChangeRepo: (fullName: string) => void;
onConnectProvider: (platform: RepositoryPlatform) => void;
onRefreshRepos: () => void;
repositories: NewSessionRepository[];
/** Recently used rows, threaded to the picker's "Recently used" section. */
recents: NewSessionRepository[];
selectedRepo: string;
/** The route's organization scope; `undefined` is a personal session. */
organizationId: string | undefined;
// Environment profile (Cloud Agent only).
profile: EffectiveAgentProfile | null;
isProfileLoading: boolean;
isProfileError: boolean;
onRetryProfile: () => void;
// Commit choice (Cloud Agent only).
autoCommit: boolean;
onAutoCommitChange: (next: boolean) => void;
// Start.
isSpawningRemote: boolean;
isStartDisabled: boolean;
onStartSession: () => void;
/** The last cloud-create rejection, or null before one. */
cloudCreateError?: CloudCreateFailure | null;
/** Re-runs the cloud create with the same draft (the retryable recovery). */
onRetryCloudCreate?: () => void;
};
115 changes: 115 additions & 0 deletions apps/mobile/src/components/agents/new-session-configure-form.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,35 @@ vi.mock('react', async () => {

// ── react-native ───────────────────────────────────────────────────
const platformState = vi.hoisted(() => ({ OS: 'android' }));
// The composer-reveal hook arms the did-events through Keyboard.addListener;
// the captured subscribers let the repro fire `keyboardDidShow` directly.
const keyboardSubscribers = vi.hoisted(() => ({
show: null as (() => void) | null,
hide: null as (() => void) | null,
}));

vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'ActivityIndicator' }));
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
Keyboard: {
addListener: vi.fn((event: string, listener: () => void) => {
const remove = (): void => {
if (event === 'keyboardDidShow') {
keyboardSubscribers.show = null;
}
if (event === 'keyboardDidHide') {
keyboardSubscribers.hide = null;
}
};
if (event === 'keyboardDidShow') {
keyboardSubscribers.show = listener;
}
if (event === 'keyboardDidHide') {
keyboardSubscribers.hide = listener;
}
return { remove };
}),
},
Platform: platformState,
ScrollView: 'ScrollView',
View: 'View',
Expand Down Expand Up @@ -103,6 +128,13 @@ vi.mock('@/components/ui/text', () => ({
Text: ({ children }: { children?: unknown }) => children,
}));

// The environment row's loading state renders `Skeleton`, whose module imports
// react-native-reanimated: this project runs in plain Node, where the
// Reanimated/worklets native entry cannot resolve (the published worklets
// build uses bundler-style extensionless imports). The primitive is a stub like
// every other UI element above; its own rendering is not under test here.
vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' }));

// ── hooks ──────────────────────────────────────────────────────────
vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({
Expand Down Expand Up @@ -173,6 +205,30 @@ function findElement(node: Node, typeName: string): Record<string, unknown> | nu
return null;
}

/** The first node below the ScrollView carrying an `onLayout` (the composer wrapper). */
function findOnLayoutHandler(
node: Node
): ((event: { nativeEvent: { layout: { y: number; height: number } } }) => void) | null {
if (node === null || typeof node !== 'object') {
return null;
}
const props = node.props ?? {};
const type = (node as { type?: unknown }).type;
if (type !== 'ScrollView' && typeof props.onLayout === 'function') {
return props.onLayout as (event: {
nativeEvent: { layout: { y: number; height: number } };
}) => void;
}
const children = props.children;
for (const child of Array.isArray(children) ? children : [children]) {
const found = findOnLayoutHandler(child as Node);
if (found) {
return found;
}
}
return null;
}

const INSTANCE: InstancePickerInstance = {
connectionId: 'conn-abc',
name: 'laptop',
Expand Down Expand Up @@ -827,4 +883,63 @@ describe('NewSessionConfigureForm', () => {
}) as Node;
expect(findElementByType(remote, 'NewSessionCloudCreateError')).toBeNull();
});

// ── 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');

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

const scrollView = findElementByType(element, 'ScrollView');
if (!scrollView) {
throw new Error('expected the form to render a ScrollView');
}
const scrollTo = vi.fn();
// The hook's ScrollView ref is the reveal's target; the plain-function
// mount leaves it on the element props (ref is a regular prop in React 19).
(scrollView.ref as { current: unknown }).current = { scrollTo };

// The keyboard-lift view shrinks the scroll viewport once the IME is up.
(scrollView.onLayout as (event: unknown) => void)({
nativeEvent: { layout: { height: 380 } },
});

// The composer card sits 16pt below the content top and is 420pt tall, so
// its bottom edge is 56pt below the lifted viewport bottom.
const onComposerLayout = findOnLayoutHandler(element);
if (!onComposerLayout) {
throw new Error('expected the composer wrapper to carry an onLayout');
}
onComposerLayout({ nativeEvent: { layout: { y: 16, height: 420 } } });

// Nothing moves while the keyboard is down — the keyboard-down state is untouched.
expect(scrollTo).not.toHaveBeenCalled();

keyboardSubscribers.show?.();
expect(scrollTo).toHaveBeenCalledTimes(1);
expect(scrollTo).toHaveBeenCalledWith({ y: 56, animated: false });
});

// ── Case 17: the restore needs the user's live offset ──
it('feeds the ScrollView onScroll into the composer reveal', 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;

const scrollView = findElementByType(element, 'ScrollView');
if (!scrollView) {
throw new Error('expected the form to render a ScrollView');
}
// Dropping either wiring would silently disable the keyboard-hide restore.
expect(typeof scrollView.onScroll).toBe('function');
expect(scrollView.scrollEventThrottle).toBe(16);

// The handler is the hook's `onScroll`: it must forward the native offset.
const onScroll = scrollView.onScroll as (event: unknown) => void;
expect(() => {
onScroll({ nativeEvent: { contentOffset: { y: 120 } } });
}).not.toThrow();
});
});
Loading
Loading