From bf23d04da1502bdc1d77c3a624aad5e14ef4019a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 21 Sep 2026 08:37:03 +0000 Subject: [PATCH] fix(mobile): reveal new-session composer pills above the keyboard Explorer finding: new-session-filled-kb-up: The Mode and model pills are drawn under the keyboard, hiding their lower half, while the same row is fully visible with the keyboard down. The user-agent explorer found this while using the app like a user. One finding per item; the explorer never edits product code. Flow: new-session-filled-kb-up Found on revision: f2181ae79 Repro: 1. set this state first: credits 30; reviews 5; org; density 560; uimode day; battery 18 discharging; relaunch; the device in light mode 2. open the app on emulator-5602 3. reach new-session-filled-kb-up 4. the capture shows the defect named below Observed: The Mode and model pills are drawn under the keyboard, hiding their lower half, while the same row is fully visible with the keyboard down. Expected: the screen renders without this defect Evidence (from the device run): - ~/.local/share/kwf/findings/explorer-new-session-filled-kb-up-the-mode-and-model-pill-6709164d/new-session-filled-kb-up.png --- .../new-session-configure-form-props.ts | 99 +++++ .../agents/new-session-configure-form.test.ts | 108 +++++ .../agents/new-session-configure-form.tsx | 185 +++------ .../agents/use-composer-reveal-scroll.test.ts | 382 ++++++++++++++++++ .../agents/use-composer-reveal-scroll.ts | 186 +++++++++ 5 files changed, 837 insertions(+), 123 deletions(-) create mode 100644 apps/mobile/src/components/agents/new-session-configure-form-props.ts create mode 100644 apps/mobile/src/components/agents/use-composer-reveal-scroll.test.ts create mode 100644 apps/mobile/src/components/agents/use-composer-reveal-scroll.ts diff --git a/apps/mobile/src/components/agents/new-session-configure-form-props.ts b/apps/mobile/src/components/agents/new-session-configure-form-props.ts new file mode 100644 index 0000000000..c9f243cd00 --- /dev/null +++ b/apps/mobile/src/components/agents/new-session-configure-form-props.ts @@ -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; + shareId: string | undefined; + voiceInputSettlerRef: RefObject<(() => Promise) | 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; +}; diff --git a/apps/mobile/src/components/agents/new-session-configure-form.test.ts b/apps/mobile/src/components/agents/new-session-configure-form.test.ts index 376d38a121..a7f2e2cf71 100644 --- a/apps/mobile/src/components/agents/new-session-configure-form.test.ts +++ b/apps/mobile/src/components/agents/new-session-configure-form.test.ts @@ -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', @@ -174,6 +199,30 @@ function findElementHeight(node: Node): number | null { 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', @@ -782,4 +831,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(); + }); }); diff --git a/apps/mobile/src/components/agents/new-session-configure-form.tsx b/apps/mobile/src/components/agents/new-session-configure-form.tsx index a339f3dcb3..40a19d9d5a 100644 --- a/apps/mobile/src/components/agents/new-session-configure-form.tsx +++ b/apps/mobile/src/components/agents/new-session-configure-form.tsx @@ -1,114 +1,22 @@ -import { type RefObject } from 'react'; import { ScrollView, View } from 'react-native'; import { useTranslation } from 'react-i18next'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { LaunchFolderField } from '@/components/agents/folder-selector'; import { NewSessionCloudCreateError } from '@/components/agents/new-session-cloud-create-error'; +import { type NewSessionConfigureFormProps } from '@/components/agents/new-session-configure-form-props'; import { renderProfileRow } from '@/components/agents/new-session-profile-row'; import { NewSessionPrompt } from '@/components/agents/new-session-prompt'; import { NewSessionRepositorySection } from '@/components/agents/new-session-repository-section'; import { NewSessionRunTarget } from '@/components/agents/new-session-run-target'; -import { - type NewSessionRepository, - type RepositoryGroup, - type RepositoryPlatform, -} from '@/components/agents/new-session-repository-state'; import { NewSessionStartButton } from '@/components/agents/new-session-start-button'; -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 { useComposerRevealScroll } from '@/components/agents/use-composer-reveal-scroll'; import { AppAwareKeyboardPaddingView } from '@/components/kilo-chat/app-aware-keyboard-padding'; import { SegmentedControl } from '@/components/ui/segmented-control'; import { Text } from '@/components/ui/text'; -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'; import { remoteSpawnInstanceDisconnectedNote } from '@/lib/remote-submit-outcome'; import { useDetailScreenBottomPadding } from '@/lib/screen-insets'; -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; - shareId: string | undefined; - voiceInputSettlerRef: RefObject<(() => Promise) | 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; -}; - /** * THE new-session screen body — one screen for every entry point (cloud, * remote CLI, share-staged). The composer, the mode and the model controls @@ -179,6 +87,17 @@ export function NewSessionConfigureForm({ // primary Start action can sit in the bar's translucent region a formSheet // leaves exposed below itself (the picker's bottom strip showed its sliver). const bottomClearance = useDetailScreenBottomPadding(); + // The two floors below keep the scroll CONTENT reachable; they do not keep + // the composer card's own bottom row (the mode/model pills) above the IME — + // the card is the first child, so it is drawn under the keyboard. This + // reveal scrolls the card's bottom edge to the viewport's bottom, changing + // only the content offset (never a size) so no surrounding layout moves. + // The hook feeds the live offset back with `onScroll`, so when the IME + // closes it can give the keyboard-down view its offset back: the form is far + // taller than the lifted viewport, and without the restore the card's top + // edge (rounded corner, top padding, the prompt's first line) comes back + // clipped under the header. + const composerReveal = useComposerRevealScroll(); // The form is edge-to-edge and the window never resizes for the IME on // either platform, so the scroll body needs two floors: the navigation-bar // inset, and the keyboard height — the composer auto-focuses on open, and @@ -202,41 +121,61 @@ export function NewSessionConfigureForm({ const body = ( { + composerReveal.onViewportLayout(event.nativeEvent.layout.height); + }} + onScroll={event => { + composerReveal.onScroll(event.nativeEvent.contentOffset.y); + }} + scrollEventThrottle={16} + onScrollBeginDrag={() => { + composerReveal.onUserScroll(); + }} > - + { + composerReveal.onComposerLayout({ + y: event.nativeEvent.layout.y, + height: event.nativeEvent.layout.height, + }); + }} + > + + ({ + show: null as (() => void) | null, + hide: null as (() => void) | null, +})); + +// No Platform in the react-native mock: the hook has one implementation for +// both platforms (the did-events fire everywhere), so any platform fork would +// crash here instead of passing silently on the mocked OS. +vi.mock('react-native', () => ({ + 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 }; + }), + }, +})); + +// The React-primitive slots are generic over the hook's actual call order; the +// mock hands out slots on demand, so a hook refactor that reorders refs does not +// silently misalign. +const slots = { + refs: [] as { current: unknown }[], + refCursor: 0, + cleanups: [] as (() => void)[], +}; + +vi.mock('react', () => ({ + useRef: (initial: unknown) => { + if (slots.refs.length <= slots.refCursor) { + slots.refs.push({ current: initial }); + } + const slot = slots.refs[slots.refCursor]; + slots.refCursor += 1; + return slot; + }, + useEffect: (effect: () => unknown) => { + const cleanup = effect(); + if (typeof cleanup === 'function') { + slots.cleanups.push(cleanup as () => void); + } + }, + useCallback: unknown>(factory: T): T => factory, +})); + +type Mounted = { + onViewportLayout: (height: number) => void; + onComposerLayout: (layout: { y: number; height: number }) => void; + onScroll: (offset: number) => void; + onUserScroll: () => void; + scrollTo: ReturnType; + unmount: () => void; +}; + +function mountHook(): Mounted { + slots.refs = []; + slots.refCursor = 0; + slots.cleanups = []; + const scrollTo = vi.fn(); + // Property container, not a bare `let`: the hook's return is assigned inside + // Harness, and control-flow narrowing of a bare variable would type it as the + // initial `undefined` at the spread below. + const produced: { current: ReturnType | undefined } = { + current: undefined, + }; + function Harness(): null { + produced.current = useComposerRevealScroll(); + return null; + } + // eslint-disable-next-line new-cap -- plain-function mount of the hook harness + Harness(); + if (!produced.current) { + throw new Error('hook produced no surface'); + } + const scrollRef = produced.current.scrollRef as unknown as { + current: { scrollTo: ReturnType } | null; + }; + scrollRef.current = { scrollTo }; + return { + ...produced.current, + scrollTo, + unmount: () => { + for (const cleanup of slots.cleanups.splice(0)) { + cleanup(); + } + }, + }; +} + +describe('resolveComposerRevealOffset', () => { + it('returns 0 when the card already fits', () => { + expect( + resolveComposerRevealOffset({ viewportHeight: 600, composerTop: 16, composerHeight: 420 }) + ).toBe(0); + }); + + it('returns the minimum offset that puts the card bottom at the viewport bottom', () => { + expect( + resolveComposerRevealOffset({ viewportHeight: 380, composerTop: 16, composerHeight: 420 }) + ).toBe(56); + }); + + it('rounds the offset up so the card keeps its last pixel', () => { + expect( + resolveComposerRevealOffset({ + viewportHeight: 380.5, + composerTop: 16.2, + composerHeight: 420.4, + }) + ).toBe(57); + }); + + it('returns 0 for an uncommitted viewport or composer', () => { + expect( + resolveComposerRevealOffset({ viewportHeight: 0, composerTop: 16, composerHeight: 420 }) + ).toBe(0); + expect( + resolveComposerRevealOffset({ viewportHeight: 380, composerTop: 16, composerHeight: 0 }) + ).toBe(0); + }); +}); + +describe('useComposerRevealScroll', () => { + beforeEach(() => { + keyboardSubscribers.show = null; + keyboardSubscribers.hide = null; + vi.stubGlobal('requestAnimationFrame', (onFrame: FrameRequestCallback) => { + onFrame(0); + return 0; + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('arms the keyboard show and hide listeners and removes them on unmount', () => { + const { unmount } = mountHook(); + expect(keyboardSubscribers.show).toBeTypeOf('function'); + expect(keyboardSubscribers.hide).toBeTypeOf('function'); + unmount(); + expect(keyboardSubscribers.show).toBeNull(); + expect(keyboardSubscribers.hide).toBeNull(); + }); + + it('Android order: reveals on the post-show viewport commit', () => { + const { onViewportLayout, onComposerLayout, scrollTo, unmount } = mountHook(); + // The unlifted baseline the body reports at mount, and the card layout. + onViewportLayout(800); + onComposerLayout({ y: 16, height: 420 }); + expect(scrollTo).not.toHaveBeenCalled(); + + // Android commits the lift AFTER keyboardDidShow: the show event alone + // computes against the pre-lift viewport, where the card still fits. + keyboardSubscribers.show?.(); + expect(scrollTo).not.toHaveBeenCalled(); + + onViewportLayout(380); + expect(scrollTo).toHaveBeenCalledTimes(1); + expect(scrollTo).toHaveBeenCalledWith({ y: 56, animated: false }); + unmount(); + }); + + it('iOS order: reveals on keyboardDidShow after the lift already committed', () => { + const { onViewportLayout, onComposerLayout, scrollTo, unmount } = mountHook(); + // iOS commits the lift (keyboardWillShow padding) while the keyboard is + // still animating in: the commit alone must not scroll. + onViewportLayout(380); + onComposerLayout({ y: 16, height: 420 }); + expect(scrollTo).not.toHaveBeenCalled(); + + keyboardSubscribers.show?.(); + expect(scrollTo).toHaveBeenCalledTimes(1); + expect(scrollTo).toHaveBeenCalledWith({ y: 56, animated: false }); + unmount(); + }); + + it('does not scroll while the keyboard is hidden, even with a short viewport', () => { + const { onViewportLayout, onComposerLayout, scrollTo, unmount } = mountHook(); + onViewportLayout(380); + onComposerLayout({ y: 16, height: 420 }); + onViewportLayout(360); + expect(scrollTo).not.toHaveBeenCalled(); + unmount(); + }); + + it('does not scroll while the card fits', () => { + const { onViewportLayout, onComposerLayout, scrollTo, unmount } = mountHook(); + keyboardSubscribers.show?.(); + onViewportLayout(800); + onComposerLayout({ y: 16, height: 420 }); + expect(scrollTo).not.toHaveBeenCalled(); + unmount(); + }); + + it('re-reveals with the larger offset when the card grows while visible', () => { + const { onViewportLayout, onComposerLayout, scrollTo, unmount } = mountHook(); + keyboardSubscribers.show?.(); + onViewportLayout(380); + onComposerLayout({ y: 16, height: 420 }); + expect(scrollTo).toHaveBeenCalledTimes(1); + expect(scrollTo).toHaveBeenLastCalledWith({ y: 56, animated: false }); + + onComposerLayout({ y: 16, height: 520 }); + expect(scrollTo).toHaveBeenCalledTimes(2); + expect(scrollTo).toHaveBeenLastCalledWith({ y: 156, animated: false }); + unmount(); + }); + + it('restores the offset when the card shrinks back to fit, and clears the reveal', () => { + const { onViewportLayout, onComposerLayout, onScroll, scrollTo, unmount } = mountHook(); + onScroll(120); + keyboardSubscribers.show?.(); + onViewportLayout(380); + onComposerLayout({ y: 16, height: 420 }); + expect(scrollTo).toHaveBeenLastCalledWith({ y: 56, animated: false }); + + // The user deleted lines / removed an attachment / a models error resolved: + // the card fits again, so the stale reveal offset would leave its top + // clipped under the header. The user's own offset comes back. + onComposerLayout({ y: 16, height: 200 }); + expect(scrollTo).toHaveBeenLastCalledWith({ y: 120, animated: false }); + + // The shrink-back already gave it back; the hide has nothing left to do. + keyboardSubscribers.hide?.(); + expect(scrollTo).toHaveBeenCalledTimes(2); + unmount(); + }); + + it('does not read a transient uncommitted measurement as a card that fits', () => { + const { onViewportLayout, onComposerLayout, scrollTo, unmount } = mountHook(); + keyboardSubscribers.show?.(); + onViewportLayout(380); + onComposerLayout({ y: 16, height: 420 }); + expect(scrollTo).toHaveBeenCalledTimes(1); + + onComposerLayout({ y: 0, height: 0 }); + onViewportLayout(0); + expect(scrollTo).toHaveBeenCalledTimes(1); + unmount(); + }); + + it('restores to the session start and reveals again when the card grows back', () => { + const { onViewportLayout, onComposerLayout, scrollTo, unmount } = mountHook(); + keyboardSubscribers.show?.(); + onViewportLayout(380); + onComposerLayout({ y: 16, height: 420 }); + onComposerLayout({ y: 16, height: 200 }); + // No offset was captured before the reveal: the session start comes back. + expect(scrollTo).toHaveBeenLastCalledWith({ y: 0, animated: false }); + + onComposerLayout({ y: 16, height: 520 }); + expect(scrollTo).toHaveBeenLastCalledWith({ y: 156, animated: false }); + unmount(); + }); + + it('a drag while the keyboard is down does not suppress the next reveal', () => { + const { onViewportLayout, onComposerLayout, onUserScroll, scrollTo, unmount } = mountHook(); + // Keyboard DOWN: the composer auto-focus was dismissed and the user + // scrolled the tall form. That grab belongs to the dead session and must + // not poison the next one — tapping back into the prompt must reveal. + onUserScroll(); + keyboardSubscribers.show?.(); + onViewportLayout(380); + onComposerLayout({ y: 16, height: 420 }); + expect(scrollTo).toHaveBeenCalledTimes(1); + expect(scrollTo).toHaveBeenCalledWith({ y: 56, animated: false }); + unmount(); + }); + + it('gives the offset back when the keyboard hides', () => { + const { onViewportLayout, onComposerLayout, scrollTo, unmount } = mountHook(); + keyboardSubscribers.show?.(); + onViewportLayout(380); + onComposerLayout({ y: 16, height: 420 }); + expect(scrollTo).toHaveBeenCalledTimes(1); + expect(scrollTo).toHaveBeenLastCalledWith({ y: 56, animated: false }); + + // The keyboard-down view must be the untouched baseline: the form is far + // taller than the lifted viewport, so the reveal offset has to come back. + keyboardSubscribers.hide?.(); + expect(scrollTo).toHaveBeenCalledTimes(2); + expect(scrollTo).toHaveBeenLastCalledWith({ y: 0, animated: false }); + unmount(); + }); + + it("gives the user's own offset back, not zero", () => { + const { onViewportLayout, onComposerLayout, onScroll, scrollTo, unmount } = mountHook(); + // The user had already scrolled the form down before the reveal. + onScroll(120); + keyboardSubscribers.show?.(); + onViewportLayout(380); + onComposerLayout({ y: 16, height: 420 }); + expect(scrollTo).toHaveBeenLastCalledWith({ y: 56, animated: false }); + + keyboardSubscribers.hide?.(); + expect(scrollTo).toHaveBeenLastCalledWith({ y: 120, animated: false }); + unmount(); + }); + + it('keeps the position when the user dragged during the session', () => { + const { onViewportLayout, onComposerLayout, onUserScroll, scrollTo, unmount } = mountHook(); + keyboardSubscribers.show?.(); + onViewportLayout(380); + onComposerLayout({ y: 16, height: 420 }); + expect(scrollTo).toHaveBeenCalledTimes(1); + + // The user scrolled away from the reveal: their position is the one to + // keep, so the hide must not scroll back. + onUserScroll(); + keyboardSubscribers.hide?.(); + expect(scrollTo).toHaveBeenCalledTimes(1); + unmount(); + }); + + it('does not restore when nothing was revealed', () => { + const { onViewportLayout, onComposerLayout, scrollTo, unmount } = mountHook(); + keyboardSubscribers.show?.(); + // The card already fits the lifted viewport: no reveal, so no restore. + onViewportLayout(800); + onComposerLayout({ y: 16, height: 420 }); + keyboardSubscribers.hide?.(); + expect(scrollTo).not.toHaveBeenCalled(); + unmount(); + }); + + it('lets a user drag win until the next show', () => { + const { onViewportLayout, onComposerLayout, onUserScroll, scrollTo, unmount } = mountHook(); + keyboardSubscribers.show?.(); + onViewportLayout(380); + onComposerLayout({ y: 16, height: 420 }); + expect(scrollTo).toHaveBeenCalledTimes(1); + + onUserScroll(); + onComposerLayout({ y: 16, height: 520 }); + onViewportLayout(360); + expect(scrollTo).toHaveBeenCalledTimes(1); + + // A hide ends the session; the next show reveals again. + keyboardSubscribers.hide?.(); + keyboardSubscribers.show?.(); + expect(scrollTo).toHaveBeenCalledTimes(2); + expect(scrollTo).toHaveBeenLastCalledWith({ y: 176, animated: false }); + unmount(); + }); +}); diff --git a/apps/mobile/src/components/agents/use-composer-reveal-scroll.ts b/apps/mobile/src/components/agents/use-composer-reveal-scroll.ts new file mode 100644 index 0000000000..0e6bc7c2a5 --- /dev/null +++ b/apps/mobile/src/components/agents/use-composer-reveal-scroll.ts @@ -0,0 +1,186 @@ +import { type RefObject, useCallback, useEffect, useRef } from 'react'; +import { Keyboard, type ScrollView } from 'react-native'; + +/** + * The minimum content offset that puts the composer card's bottom edge at the + * scroll viewport's bottom, or `0` when the card already fits (or either + * measurement is not committed yet). `ceil` keeps the card's last pixel above + * the viewport edge; a larger offset would hide the input's first line, so this + * is the single source of the reveal offset. + */ +export function resolveComposerRevealOffset({ + viewportHeight, + composerTop, + composerHeight, +}: { + viewportHeight: number; + composerTop: number; + composerHeight: number; +}): number { + if (viewportHeight <= 0 || composerHeight <= 0) { + return 0; + } + return Math.max(0, Math.ceil(composerTop + composerHeight - viewportHeight)); +} + +export type ComposerRevealScroll = { + /** The scroll body's ref; the reveal scrolls this view. */ + scrollRef: RefObject; + /** Feed the scroll body's layout height (wire to the ScrollView `onLayout`). */ + onViewportLayout(height: number): void; + /** Feed the composer card's layout (wire to its wrapper `onLayout`). */ + onComposerLayout(layout: { y: number; height: number }): void; + /** Feed the user's live content offset (wire to the ScrollView `onScroll`). */ + onScroll(offset: number): void; + /** The user grabbed the scroll body: their intent wins for this keyboard session. */ + onUserScroll(): void; +}; + +/** + * Reveals the composer card's bottom row (the mode/model pills) above the soft + * keyboard on the New session screen. + * + * Explorer finding `new-session-filled-kb-up` (revision `f2181ae79`, capture + * `explorer-new-session-filled-kb-up-the-mode-and-model-pill-6709164d`): with + * the keyboard up, the composer's mode/model pill row is clipped by the IME's + * top edge — only the top of the pills shows — while the same row is fully + * visible with the keyboard down. + * + * The keyboard-lift view (`AppAwareKeyboardPaddingView`) shrinks the scroll + * frame to end exactly at the IME's top edge, but the content offset stays `0`: + * the composer card is the first child, so its bottom falls below that clip + * line. `automaticallyAdjustKeyboardInsets` only scrolls the FOCUSED input into + * view and is inert on Android, and the input's 3-line minimum floors the + * card's height, so the pills stay hidden. This hook changes only the scroll + * offset — never a size — so no surrounding layout moves. + * + * The scroll must run against the COMMITTED viewport and card. Android commits + * the lift AFTER `keyboardDidShow` (the padding view uses the did-events there), + * iOS BEFORE it (the will-events), so the show event and the two layout commits + * all call `reveal()`; whichever lands last wins and the others are no-ops. + * + * Two rules keep the keyboard-down rendering intact: + * + * - The drag flag is scoped to the keyboard session. `keyboardDidShow` starts a + * session and clears any grab made while the keyboard was down, so a scroll + * of the tall form before the next focus cannot suppress the reveal — that + * grab belongs to a dead session. A grab DURING the session still wins for + * the rest of it. + * - The session's first reveal captures the offset the user's own scroll had + * reached, and `keyboardDidHide` scrolls it back. The form is far taller than + * the lifted viewport, so without the restore the keyboard-down view returns + * scrolled — the card's top edge (rounded corner, top padding, the prompt's + * first line) clipped under the header. A drag that overruled the reveal + * keeps the user's position: their offset is what the hide gives back. + * + * A card that shrinks back to fit the lifted viewport (deleted lines, a removed + * attachment, a models error resolving) must not leave the body parked at the + * old reveal offset: the reveal offset is valid only while the card is taller + * than the viewport, so the shrink returns the user's own offset immediately. + */ +export function useComposerRevealScroll(): ComposerRevealScroll { + const scrollRef = useRef(null); + const keyboardVisibleRef = useRef(false); + const userDraggedRef = useRef(false); + const viewportHeightRef = useRef(0); + const composerLayoutRef = useRef({ y: 0, height: 0 }); + const currentOffsetRef = useRef(0); + const revealedRef = useRef(false); + const preRevealOffsetRef = useRef(null); + + const reveal = useCallback(() => { + if (!keyboardVisibleRef.current || userDraggedRef.current) { + return; + } + const viewportHeight = viewportHeightRef.current; + const composerHeight = composerLayoutRef.current.height; + if (viewportHeight <= 0 || composerHeight <= 0) { + // Measurements not committed yet: a zero offset here would be a guess, + // so leave the offset alone and let the layout commit call again. + return; + } + const offset = resolveComposerRevealOffset({ + viewportHeight, + composerTop: composerLayoutRef.current.y, + composerHeight, + }); + if (offset === 0) { + // The card fits the lifted viewport again (lines deleted, an attachment + // removed, a models error resolved): the reveal offset is now stale, so + // give the user's own offset back — otherwise the card's top (rounded + // corner, top padding, the prompt's first line) stays clipped under the + // header for the rest of the session. + if (revealedRef.current) { + scrollRef.current?.scrollTo({ y: preRevealOffsetRef.current ?? 0, animated: false }); + revealedRef.current = false; + preRevealOffsetRef.current = null; + } + return; + } + if (!revealedRef.current) { + // The user's own offset at the session's first reveal is the one the hide + // gives back; a re-reveal (e.g. the card grew) keeps the original capture. + preRevealOffsetRef.current = currentOffsetRef.current; + revealedRef.current = true; + } + scrollRef.current?.scrollTo({ y: offset, animated: false }); + }, []); + + useEffect(() => { + // The did-events fire on both platforms and the reveal must run after the + // keyboard is fully up anyway, so one listener pair serves both. + const show = Keyboard.addListener('keyboardDidShow', () => { + // A new keyboard session: a grab made while the keyboard was down must + // not suppress this session's reveal. + keyboardVisibleRef.current = true; + userDraggedRef.current = false; + revealedRef.current = false; + preRevealOffsetRef.current = null; + reveal(); + }); + const hide = Keyboard.addListener('keyboardDidHide', () => { + // Read the drag flag before clearing it: a drag that overruled the reveal + // leaves the user in control of the offset. + const userDragged = userDraggedRef.current; + if (revealedRef.current && !userDragged && preRevealOffsetRef.current !== null) { + // The lifted viewport grows back on hide; without this the tall form + // stays parked at the reveal offset and the card's top is clipped. + scrollRef.current?.scrollTo({ y: preRevealOffsetRef.current, animated: false }); + } + keyboardVisibleRef.current = false; + revealedRef.current = false; + preRevealOffsetRef.current = null; + userDraggedRef.current = false; + }); + return () => { + show.remove(); + hide.remove(); + }; + }, [reveal]); + + const onViewportLayout = useCallback( + (height: number) => { + viewportHeightRef.current = height; + reveal(); + }, + [reveal] + ); + + const onComposerLayout = useCallback( + (layout: { y: number; height: number }) => { + composerLayoutRef.current = layout; + reveal(); + }, + [reveal] + ); + + const onUserScroll = useCallback(() => { + userDraggedRef.current = true; + }, []); + + const onScroll = useCallback((offset: number) => { + currentOffsetRef.current = offset; + }, []); + + return { scrollRef, onViewportLayout, onComposerLayout, onScroll, onUserScroll }; +}