From bb676ef086ac5d2a69486c33e5a315c2bbbd2610 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 3 Sep 2026 20:27:19 +0200 Subject: [PATCH] Smooth mobile keyboard transitions (#4275) * perf(app): smooth mobile keyboard transitions Animating dock padding forced Fabric and Yoga to relayout the chat tree on every keyboard frame, producing visible stepping and React list churn. Keep the surface full-height and move it as one translated unit, with scroll range and portal measurements updated only after keyboard motion settles.\n\niOS keeps its native keyboard animation driver behind the same component boundary because its Reanimated signal reports the endpoint at animation start. * fix(app): isolate native keyboard implementation --- docs/floating-panels.md | 46 ++++++--- .../app/src/agent-stream/strategy-native.tsx | 57 ++++++----- .../src/components/adaptive-modal-sheet.tsx | 2 +- .../src/components/archived-agent-callout.tsx | 13 +-- .../src/components/keyboard-dock.native.tsx | 12 +-- .../keyboard-translate-view.android.tsx | 27 ++++++ .../keyboard-translate-view.ios.tsx | 33 +++++++ .../components/keyboard-translate-view.tsx | 15 +++ .../components/ui/autocomplete-popover.tsx | 95 +++++++++++-------- .../app/src/composer/draft/workspace-tab.tsx | 19 +--- packages/app/src/composer/index.tsx | 19 ++-- .../app/src/hooks/keyboard-shift-context.ts | 11 +++ .../app/src/hooks/keyboard-shift-policy.ts | 20 ++++ .../hooks/use-keyboard-shift-style.test.ts | 24 +++++ .../app/src/hooks/use-keyboard-shift-style.ts | 65 +++++++++---- .../hooks/use-keyboard-visibility.native.ts | 22 ++++- .../app/src/hooks/use-keyboard-visibility.ts | 2 +- packages/app/src/panels/agent-panel.tsx | 4 +- .../app/src/screens/new-workspace-screen.tsx | 16 +--- 19 files changed, 343 insertions(+), 159 deletions(-) create mode 100644 packages/app/src/components/keyboard-translate-view.android.tsx create mode 100644 packages/app/src/components/keyboard-translate-view.ios.tsx create mode 100644 packages/app/src/components/keyboard-translate-view.tsx diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 9ffe03fddf..69e8f2b105 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -134,19 +134,39 @@ The fix for transforms is Gotcha 3. The fix for context is Gotcha 7. ## Gotcha 3 — Keyboard layout and portal anchors -Move the chat surface with worklet-driven bottom padding from -`KeyboardShiftProvider`. Padding keeps the stream, composer, visual position, -and native hit testing in the same layout. Do not translate the stream and -composer independently. +`KeyboardTranslateView` owns visual keyboard motion. Android uses the +controller's Reanimated signal. iOS uses its native-driver `Animated.event` +signal because the stock iOS Reanimated value changes at move start, while +writing a replacement Reanimated value every frame interrupts UIKit's hide +animation. Keep this platform choice inside `KeyboardTranslateView`. -Do not use the controller's raw keyboard progress for the padding. It can retain -a nonzero value after the keyboard closes. The shared provider normalizes that -state and reconciles native animation-end events. +`KeyboardDock` always keeps the chat surface at full height and wraps it in +`KeyboardTranslateView`. The panel root clips it at the header edge, so rows +slide under the header on open and out from under it on close. Do not swap the +dock to keyboard padding at rest. Changing its height creates either a blank +band or a paused transition when the inverted list updates its viewport. -`measureInWindow` already includes the dock's current padding layout. When a -portal opens, snapshot the current shift and apply only the subsequent shift -delta to its animated `bottom`. Adding the full shift moves the portal twice and -can place it over the composer controls. +Do not animate a layout prop through the keyboard transition. On Android +Fabric, each animated layout update clones the shadow tree and runs Yoga over +the dock subtree. This measured about 10 ms per frame on the emulator and the +mounted layouts arrived in bursts every 2–4 frames. A transform does not dirty +layout. Preserve the translated dock's history scroll range with a far-end +content inset on the inverted stream list. Update that inset only when keyboard +motion settles; never drive it per frame. + +Move the stream and composer together through `KeyboardDock`. Do not translate +them independently. + +`KeyboardShiftProvider` owns the normalized shift used for settled insets and +portal geometry, plus the `isMoving` worklet value. It reconciles native +animation-end events because the controller can retain a nonzero value after +the keyboard closes. + +Measure portal anchors while the dock is at rest. If a popover opens during +motion, re-measure when `isMoving` settles. `measureInWindow` includes the +dock's current layout and transform. Snapshot the shift at measurement time and +apply only the subsequent shift delta to the animated `bottom`. Adding the full +shift moves the portal twice and can place it over the composer controls. The provider also reconciles iOS from the controller's native `onEnd` event. The controller's stock iOS shared values update at move start and during an @@ -155,10 +175,6 @@ leave the last height/progress pair stuck in either the open or closed state. Keep that terminal reconciliation on the UI thread; a later focus or blur must not be required to repair the offset. -Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only -to refresh the snapshot if the keyboard was mid-transition when the popover -opened. - ## Gotcha 4 — Host-relative positioning before platform offsets The generic anchored-overlay rule is: diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index b51954a6d0..8acdd92f4f 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -9,7 +9,6 @@ import { } from "react"; import { FlatList, - Keyboard, Platform, View, type LayoutChangeEvent, @@ -23,6 +22,8 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; import type { StreamItem } from "@/types/stream"; import type { Theme } from "@/styles/theme"; import { useStableEvent } from "@/hooks/use-stable-event"; +import { useSettledKeyboardShift } from "@/hooks/keyboard-shift-context"; +import { resolveStreamKeyboardInset } from "@/hooks/keyboard-shift-policy"; import { useRevisedHistoryRows } from "./history-row-revision"; import { useBottomAnchorController } from "./bottom-anchor-controller"; import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss"; @@ -104,6 +105,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const scrollOffsetYRef = useRef(0); const isUserScrollActiveRef = useRef(false); const scrollKeyboardDismiss = useScrollKeyboardDismiss(); + const settledKeyboardShift = useSettledKeyboardShift(); const userScrollEndFrameIdRef = useRef(null); const programmaticScrollEventBudgetRef = useRef(0); const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false); @@ -273,6 +275,34 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom" ? undefined : DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION; + const streamKeyboardInset = useMemo( + () => + resolveStreamKeyboardInset({ + platform: Platform.OS === "ios" ? "ios" : "android", + settledShift: settledKeyboardShift, + }), + [settledKeyboardShift], + ); + const listContentContainerStyle = useMemo( + () => [ + baseListContentContainerStyle, + { paddingBottom: streamKeyboardInset.contentContainerPaddingBottom }, + ], + [baseListContentContainerStyle, streamKeyboardInset.contentContainerPaddingBottom], + ); + const listInsetProps = useMemo( + () => + streamKeyboardInset.contentInset + ? { + automaticallyAdjustContentInsets: false, + automaticallyAdjustsScrollIndicatorInsets: false, + contentInsetAdjustmentBehavior: "never" as const, + contentInset: streamKeyboardInset.contentInset, + scrollIndicatorInsets: streamKeyboardInset.contentInset, + } + : {}, + [streamKeyboardInset.contentInset], + ); useEffect(() => { streamViewportMetricsRef.current = { @@ -305,27 +335,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }; }, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]); - useEffect(() => { - const keyboardEvents = [ - "keyboardWillShow", - "keyboardWillHide", - "keyboardDidShow", - "keyboardDidHide", - "keyboardWillChangeFrame", - "keyboardDidChangeFrame", - ] as const; - const subscriptions = keyboardEvents.map((eventName) => - Keyboard.addListener(eventName, () => { - markNativeViewportSettling(); - }), - ); - return () => { - for (const subscription of subscriptions) { - subscription.remove(); - } - clearNativeViewportSettling(); - }; - }, [clearNativeViewportSettling, markNativeViewportSettling]); + useEffect(() => () => clearNativeViewportSettling(), [clearNativeViewportSettling]); useEffect(() => { bottomAnchorController.prepareForStickyContentChange(); @@ -560,6 +570,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat // data or the live header changes, preserving the row identities above. return ( snapPoints ?? ["65%", "90%"], [snapPoints]); const compactSafeAreaPadding = useMemo( () => diff --git a/packages/app/src/components/archived-agent-callout.tsx b/packages/app/src/components/archived-agent-callout.tsx index 8982f6e06a..4a93d12a31 100644 --- a/packages/app/src/components/archived-agent-callout.tsx +++ b/packages/app/src/components/archived-agent-callout.tsx @@ -2,11 +2,10 @@ import { useCallback, useMemo, useState } from "react"; import { View, Text } from "react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; -import Animated from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; +import { KeyboardTranslateView } from "@/components/keyboard-translate-view"; import { Button } from "@/components/ui/button"; import type { Theme } from "@/styles/theme"; import { toErrorMessage } from "@/utils/error-messages"; @@ -24,11 +23,9 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout const [isUnarchiving, setIsUnarchiving] = useState(false); const [unarchiveError, setUnarchiveError] = useState(null); - const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" }); - const containerStyle = useMemo( - () => [styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle], - [insets.bottom, keyboardAnimatedStyle], + () => [styles.container, { paddingBottom: insets.bottom }], + [insets.bottom], ); const handleUnarchive = useCallback(async () => { @@ -44,7 +41,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout }, [client, isConnected, isUnarchiving, agentId]); return ( - + @@ -67,7 +64,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout - + ); } diff --git a/packages/app/src/components/keyboard-dock.native.tsx b/packages/app/src/components/keyboard-dock.native.tsx index 4fa9ea4a7a..d26c6892dc 100644 --- a/packages/app/src/components/keyboard-dock.native.tsx +++ b/packages/app/src/components/keyboard-dock.native.tsx @@ -1,21 +1,15 @@ import type { ReactNode } from "react"; import type { ViewProps } from "react-native"; -import Animated, { useAnimatedStyle } from "react-native-reanimated"; -import { useKeyboardShift } from "@/hooks/keyboard-shift-context"; +import { KeyboardTranslateView } from "@/components/keyboard-translate-view"; interface KeyboardDockProps extends ViewProps { children: ReactNode; } export function KeyboardDock({ children, style, ...props }: KeyboardDockProps) { - const { shift } = useKeyboardShift(); - const keyboardPaddingStyle = useAnimatedStyle(() => ({ - paddingBottom: shift.value, - })); - return ( - + {children} - + ); } diff --git a/packages/app/src/components/keyboard-translate-view.android.tsx b/packages/app/src/components/keyboard-translate-view.android.tsx new file mode 100644 index 0000000000..550c15f8e4 --- /dev/null +++ b/packages/app/src/components/keyboard-translate-view.android.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from "react"; +import type { ViewProps } from "react-native"; +import Animated, { useAnimatedStyle } from "react-native-reanimated"; +import { useKeyboardShift } from "@/hooks/keyboard-shift-context"; + +interface KeyboardTranslateViewProps extends ViewProps { + children: ReactNode; + enabled?: boolean; +} + +export function KeyboardTranslateView({ + children, + enabled = true, + style, + ...props +}: KeyboardTranslateViewProps) { + const { shift } = useKeyboardShift(); + const keyboardStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: enabled ? -shift.value : 0 }], + })); + + return ( + + {children} + + ); +} diff --git a/packages/app/src/components/keyboard-translate-view.ios.tsx b/packages/app/src/components/keyboard-translate-view.ios.tsx new file mode 100644 index 0000000000..7a3ba7e992 --- /dev/null +++ b/packages/app/src/components/keyboard-translate-view.ios.tsx @@ -0,0 +1,33 @@ +import { useMemo, type ReactNode } from "react"; +import { Animated, type ViewProps } from "react-native"; +import { useKeyboardAnimation } from "react-native-keyboard-controller"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +interface KeyboardTranslateViewProps extends ViewProps { + children: ReactNode; + enabled?: boolean; +} + +export function KeyboardTranslateView({ + children, + enabled = true, + style, + ...props +}: KeyboardTranslateViewProps) { + const insets = useSafeAreaInsets(); + const { height, progress } = useKeyboardAnimation(); + const translateY = useMemo( + () => Animated.add(height, Animated.multiply(progress, insets.bottom)), + [height, insets.bottom, progress], + ); + const keyboardStyle = useMemo( + () => ({ transform: [{ translateY: enabled ? translateY : 0 }] }), + [enabled, translateY], + ); + + return ( + + {children} + + ); +} diff --git a/packages/app/src/components/keyboard-translate-view.tsx b/packages/app/src/components/keyboard-translate-view.tsx new file mode 100644 index 0000000000..d866fc943c --- /dev/null +++ b/packages/app/src/components/keyboard-translate-view.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; +import { View, type ViewProps } from "react-native"; + +interface KeyboardTranslateViewProps extends ViewProps { + children: ReactNode; + enabled?: boolean; +} + +export function KeyboardTranslateView({ + children, + enabled: _enabled, + ...props +}: KeyboardTranslateViewProps) { + return {children}; +} diff --git a/packages/app/src/components/ui/autocomplete-popover.tsx b/packages/app/src/components/ui/autocomplete-popover.tsx index df9bc52fd1..ad5ae92f51 100644 --- a/packages/app/src/components/ui/autocomplete-popover.tsx +++ b/packages/app/src/components/ui/autocomplete-popover.tsx @@ -1,7 +1,20 @@ -import { useEffect, useMemo, useState, type ReactElement, type RefObject } from "react"; -import { Keyboard, View, useWindowDimensions } from "react-native"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, + type RefObject, +} from "react"; +import { View, useWindowDimensions } from "react-native"; import { Portal } from "@gorhom/portal"; -import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated"; +import Animated, { + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, +} from "react-native-reanimated"; +import { scheduleOnRN } from "react-native-worklets"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet } from "react-native-unistyles"; import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete"; @@ -66,57 +79,57 @@ export function AutocompletePopover({ const windowDimensions = useWindowDimensions(); const safeAreaInsets = useSafeAreaInsets(); const portalHostName = useFloatingPanelPortalHostName(); - const { shift } = useKeyboardShift(); + const { shift, isMoving } = useKeyboardShift(); const measuredShift = useSharedValue(0); + const measurementGeneration = useRef(0); + const canMeasure = visible && (options.length === 0 || selectedIndex >= 0); + + const remeasure = useCallback(() => { + if (!canMeasure) return; + const anchorElement = anchorRef.current; + if (!anchorElement) return; + const generation = measurementGeneration.current; + void Promise.all([ + measureElement(anchorElement), + measureFloatingPanelPortalHost(portalHostName), + ]).then(([anchorRect, hostRect]) => { + if (generation !== measurementGeneration.current || !hostRect) return undefined; + setRelativeAnchorRect({ + x: anchorRect.x - hostRect.x, + y: anchorRect.y - hostRect.y, + width: anchorRect.width, + hostHeight: hostRect.height, + }); + measuredShift.value = shift.value; + return undefined; + }); + }, [anchorRef, canMeasure, measuredShift, portalHostName, shift]); useEffect(() => { - if (!visible || (options.length > 0 && selectedIndex < 0)) { + measurementGeneration.current += 1; + if (!canMeasure) { setRelativeAnchorRect(null); return; } - let cancelled = false; - const remeasure = () => { - const anchorElement = anchorRef.current; - if (!anchorElement) return; - void Promise.all([ - measureElement(anchorElement), - measureFloatingPanelPortalHost(portalHostName), - ]).then(([anchorRect, hostRect]) => { - if (cancelled || !hostRect) return undefined; - setRelativeAnchorRect({ - x: anchorRect.x - hostRect.x, - y: anchorRect.y - hostRect.y, - width: anchorRect.width, - hostHeight: hostRect.height, - }); - measuredShift.value = shift.value; - return undefined; - }); - }; - remeasure(); const raf = requestAnimationFrame(remeasure); - const subscriptions = (["keyboardDidShow", "keyboardDidHide"] as const).map((event) => - Keyboard.addListener(event, () => requestAnimationFrame(remeasure)), - ); return () => { - cancelled = true; + measurementGeneration.current += 1; cancelAnimationFrame(raf); - for (const sub of subscriptions) sub.remove(); }; - }, [ - visible, - options.length, - selectedIndex, - anchorRef, - portalHostName, - measuredShift, - shift, - windowDimensions.width, - windowDimensions.height, - ]); + }, [canMeasure, remeasure, windowDimensions.width, windowDimensions.height]); + + useAnimatedReaction( + () => isMoving.value, + (moving, wasMoving) => { + if (wasMoving && !moving) { + scheduleOnRN(remeasure); + } + }, + [isMoving, remeasure], + ); const baseStyle = useMemo(() => { if (!relativeAnchorRect) return null; diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 167a8886b8..e9047eb60a 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -1,10 +1,9 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native"; import { useTranslation } from "react-i18next"; -import ReanimatedAnimated from "react-native-reanimated"; import { StyleSheet } from "react-native-unistyles"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; +import { KeyboardTranslateView } from "@/components/keyboard-translate-view"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; import invariant from "tiny-invariant"; import { Composer } from "@/composer"; @@ -624,17 +623,9 @@ export function WorkspaceDraftAgentTab({ focusInputRef.current = focus; }, []); - const { style: composerKeyboardStyle } = useKeyboardShiftStyle({ - mode: "translate", - }); - const inputAreaWrapperStyle = useMemo( - () => [ - animatedStaticStyles.inputAreaWrapper, - { paddingBottom: insets.bottom }, - composerKeyboardStyle, - ], - [insets.bottom, composerKeyboardStyle], + () => [animatedStaticStyles.inputAreaWrapper, { paddingBottom: insets.bottom }], + [insets.bottom], ); const handleDropdownCloseFocus = useCallback(() => { @@ -678,7 +669,7 @@ export function WorkspaceDraftAgentTab({ )} - + {importPillPress ? ( @@ -711,7 +702,7 @@ export function WorkspaceDraftAgentTab({ agentControls={composerAgentControls} isCompactLayout={isCompactComposerLayout} /> - + ); } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 989382fe79..5a1d51f1fc 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -34,7 +34,6 @@ import { Paperclip, } from "lucide-react-native"; import * as Clipboard from "expo-clipboard"; -import Animated from "react-native-reanimated"; import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; import { AgentControls, @@ -42,6 +41,7 @@ import { type DraftAgentControlsProps, } from "@/composer/agent-controls"; import { ContextWindowMeter } from "@/components/context-window-meter"; +import { KeyboardTranslateView } from "@/components/keyboard-translate-view"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store"; import { useFilePicker } from "@/hooks/use-file-picker"; @@ -102,7 +102,6 @@ import { import { resolveAgentControlsMode } from "@/composer/agent-controls/mode"; import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; import { resolveActiveSendBehavior } from "./input/state"; -import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; @@ -1839,11 +1838,6 @@ function ComposerContentImpl({ focusMessageInputWithPlatformStrategy(messageInputRef); }, []); - const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ - mode: "translate", - enabled: !externalKeyboardShift, - }); - const isVoiceModeForAgent = resolveIsVoiceModeForAgent(voice, serverId, agentId); const handleToggleRealtimeVoice = useCallback(() => { @@ -2231,10 +2225,6 @@ function ComposerContentImpl({ [githubSearchItems, selectedAttachments, handleToggleGithubItem], ); - const composerContainerStyle = useMemo( - () => [animatedStaticStyles.container, keyboardAnimatedStyle], - [keyboardAnimatedStyle], - ); const inputAreaContainerStyle = useMemo( () => [styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked], [isComposerLocked], @@ -2319,7 +2309,10 @@ function ComposerContentImpl({ focusMessageInputForKeyboardAction={focusMessageInputForKeyboardAction} isMessageInputFocused={isMessageInputFocused} /> - + {/* Input area */} @@ -2413,7 +2406,7 @@ function ComposerContentImpl({ - + ); } diff --git a/packages/app/src/hooks/keyboard-shift-context.ts b/packages/app/src/hooks/keyboard-shift-context.ts index a6eeb53729..9d41f13224 100644 --- a/packages/app/src/hooks/keyboard-shift-context.ts +++ b/packages/app/src/hooks/keyboard-shift-context.ts @@ -3,10 +3,12 @@ import type { SharedValue } from "react-native-reanimated"; export interface KeyboardShiftContextValue { shift: SharedValue; + isMoving: SharedValue; bottomInset: SharedValue; } export const KeyboardShiftContext = createContext(null); +export const SettledKeyboardShiftContext = createContext(null); /** Read the app-wide keyboard inset without loading its native provider implementation. */ export function useKeyboardShift(): KeyboardShiftContextValue { @@ -16,3 +18,12 @@ export function useKeyboardShift(): KeyboardShiftContextValue { } return context; } + +/** Read the keyboard inset published after native keyboard motion has settled. */ +export function useSettledKeyboardShift(): number { + const context = useContext(SettledKeyboardShiftContext); + if (context === null) { + throw new Error("useSettledKeyboardShift must be used inside KeyboardShiftProvider"); + } + return context; +} diff --git a/packages/app/src/hooks/keyboard-shift-policy.ts b/packages/app/src/hooks/keyboard-shift-policy.ts index 68e013e1b1..19c021a26c 100644 --- a/packages/app/src/hooks/keyboard-shift-policy.ts +++ b/packages/app/src/hooks/keyboard-shift-policy.ts @@ -1,5 +1,25 @@ export const DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT = 120; +export function resolveStreamKeyboardInset(input: { + platform: "android" | "ios"; + settledShift: number; +}): { + contentContainerPaddingBottom: number; + contentInset: { bottom: number } | undefined; +} { + const settledShift = Math.max(0, input.settledShift); + if (input.platform === "ios") { + return { + contentContainerPaddingBottom: 0, + contentInset: { bottom: settledShift }, + }; + } + return { + contentContainerPaddingBottom: settledShift, + contentInset: undefined, + }; +} + export function shouldUseCompactExplorerKeyboardPadding(input: { isGit: boolean; explorerTab: "changes" | "files" | "pr"; diff --git a/packages/app/src/hooks/use-keyboard-shift-style.test.ts b/packages/app/src/hooks/use-keyboard-shift-style.test.ts index 96165d7f1f..bada7dd74c 100644 --- a/packages/app/src/hooks/use-keyboard-shift-style.test.ts +++ b/packages/app/src/hooks/use-keyboard-shift-style.test.ts @@ -1,10 +1,34 @@ import { describe, expect, it } from "vitest"; import { + resolveStreamKeyboardInset, shouldReconcileHiddenKeyboardEnd, resolveKeyboardShift, shouldUseCompactExplorerKeyboardPadding, } from "./keyboard-shift-policy"; +describe("resolveStreamKeyboardInset", () => { + it("uses the native scroll inset on iOS without changing content size", () => { + expect(resolveStreamKeyboardInset({ platform: "ios", settledShift: 311 })).toEqual({ + contentContainerPaddingBottom: 0, + contentInset: { bottom: 311 }, + }); + }); + + it("keeps Android's exact content-container padding behavior", () => { + expect(resolveStreamKeyboardInset({ platform: "android", settledShift: 311 })).toEqual({ + contentContainerPaddingBottom: 311, + contentInset: undefined, + }); + }); + + it("does not expose a negative inset", () => { + expect(resolveStreamKeyboardInset({ platform: "ios", settledShift: -1 })).toEqual({ + contentContainerPaddingBottom: 0, + contentInset: { bottom: 0 }, + }); + }); +}); + describe("resolveKeyboardShift", () => { it("keeps the existing open-keyboard offset behavior", () => { expect( diff --git a/packages/app/src/hooks/use-keyboard-shift-style.ts b/packages/app/src/hooks/use-keyboard-shift-style.ts index 6365771573..126ab9e9b3 100644 --- a/packages/app/src/hooks/use-keyboard-shift-style.ts +++ b/packages/app/src/hooks/use-keyboard-shift-style.ts @@ -1,4 +1,4 @@ -import { createElement, useEffect, useMemo, type ReactNode } from "react"; +import { createElement, useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { Platform } from "react-native"; import type { ViewStyle } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -8,16 +8,22 @@ import { } from "react-native-keyboard-controller"; import { useAnimatedStyle, + useAnimatedReaction, useDerivedValue, useSharedValue, type SharedValue, } from "react-native-reanimated"; +import { scheduleOnRN } from "react-native-worklets"; import { DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, resolveKeyboardShift, shouldReconcileHiddenKeyboardEnd, } from "@/hooks/keyboard-shift-policy"; -import { KeyboardShiftContext, useKeyboardShift } from "@/hooks/keyboard-shift-context"; +import { + KeyboardShiftContext, + SettledKeyboardShiftContext, + useKeyboardShift, +} from "@/hooks/keyboard-shift-context"; type KeyboardShiftMode = "translate" | "padding"; @@ -26,24 +32,16 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { const { height: keyboardHeight, progress: keyboardProgress } = useReanimatedKeyboardAnimation(); const bottomInset = useSharedValue(insets.bottom); const isIos = Platform.OS === "ios"; + const isMoving = useSharedValue(false); + const [settledShift, setSettledShift] = useState(0); + const publishSettledShift = useCallback((nextShift: number) => { + setSettledShift((currentShift) => (currentShift === nextShift ? currentShift : nextShift)); + }, []); useEffect(() => { bottomInset.value = insets.bottom; }, [bottomInset, insets.bottom]); - useGenericKeyboardHandler( - { - onEnd: (event) => { - "worklet"; - if (isIos && shouldReconcileHiddenKeyboardEnd(event)) { - keyboardHeight.value = 0; - keyboardProgress.value = 0; - } - }, - }, - [isIos, keyboardHeight, keyboardProgress], - ); - const shift = useDerivedValue(() => { "worklet"; return resolveKeyboardShift({ @@ -55,15 +53,48 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { }); }); + useGenericKeyboardHandler( + { + onStart: () => { + "worklet"; + isMoving.value = true; + }, + onEnd: (event) => { + "worklet"; + if (isIos && shouldReconcileHiddenKeyboardEnd(event)) { + keyboardHeight.value = 0; + keyboardProgress.value = 0; + } + isMoving.value = false; + }, + }, + [isIos, isMoving, keyboardHeight, keyboardProgress], + ); + + useAnimatedReaction( + () => ({ moving: isMoving.value, shift: shift.value }), + (current, previous) => { + if (!current.moving && (previous === null || previous.moving)) { + scheduleOnRN(publishSettledShift, current.shift); + } + }, + [isMoving, publishSettledShift, shift], + ); + const value = useMemo( () => ({ shift, + isMoving, bottomInset, }), - [bottomInset, shift], + [bottomInset, isMoving, shift], ); - return createElement(KeyboardShiftContext.Provider, { value }, children); + return createElement( + KeyboardShiftContext.Provider, + { value }, + createElement(SettledKeyboardShiftContext.Provider, { value: settledShift }, children), + ); } export function useKeyboardShiftStyle(input: { mode: KeyboardShiftMode; enabled?: boolean }): { diff --git a/packages/app/src/hooks/use-keyboard-visibility.native.ts b/packages/app/src/hooks/use-keyboard-visibility.native.ts index cb222d9e0c..f15b0fe7c7 100644 --- a/packages/app/src/hooks/use-keyboard-visibility.native.ts +++ b/packages/app/src/hooks/use-keyboard-visibility.native.ts @@ -1,5 +1,21 @@ -import { useKeyboardState } from "react-native-keyboard-controller"; +import { useSyncExternalStore } from "react"; +import { KeyboardController, KeyboardEvents } from "react-native-keyboard-controller"; -export function useKeyboardVisibility(): boolean { - return useKeyboardState((state) => state.isVisible); +const subscribe = (onStoreChange: () => void) => { + const subscriptions = (["keyboardWillShow", "keyboardDidHide"] as const).map((event) => + KeyboardEvents.addListener(event, onStoreChange), + ); + return () => { + for (const subscription of subscriptions) { + subscription.remove(); + } + }; +}; + +export function useKeyboardVisibility(enabled = true): boolean { + return useSyncExternalStore( + subscribe, + () => (enabled ? KeyboardController.isVisible() : false), + () => false, + ); } diff --git a/packages/app/src/hooks/use-keyboard-visibility.ts b/packages/app/src/hooks/use-keyboard-visibility.ts index 793a521b6f..375734124d 100644 --- a/packages/app/src/hooks/use-keyboard-visibility.ts +++ b/packages/app/src/hooks/use-keyboard-visibility.ts @@ -1,3 +1,3 @@ -export function useKeyboardVisibility(): boolean { +export function useKeyboardVisibility(_enabled = true): boolean { return false; } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 5156ee8f28..2ce4aaff3d 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1390,7 +1390,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ setText={agentInputDraft.replaceText} onRewindComplete={handleRewindComplete} > - + {contentContainer} @@ -1823,6 +1823,8 @@ const styles = StyleSheet.create((theme) => ({ root: { flex: 1, backgroundColor: theme.colors.surface0, + // KeyboardDock translates the chat surface while the keyboard moves; clip it at the header edge. + overflow: "hidden", }, container: { flex: 1, diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 947190ccd0..10261c1eae 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -4,13 +4,13 @@ import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { Pressable, StyleSheet as RNStyleSheet, Text, View } from "react-native"; import type { PressableStateCallbackType } from "react-native"; -import ReanimatedAnimated from "react-native-reanimated"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { createNameId } from "mnemonic-id"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native"; import { Composer } from "@/composer"; +import { KeyboardTranslateView } from "@/components/keyboard-translate-view"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { resolveComposerAttachmentSubmitFormat, @@ -62,7 +62,6 @@ import { useWorkspaceDraftSubmissionStore, type PendingWorkspaceDraftSetup, } from "@/stores/workspace-draft-submission-store"; -import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; import { useFormPreferences } from "@/hooks/use-form-preferences"; @@ -2178,15 +2177,6 @@ export function NewWorkspaceScreen({ [isCompact, insets.bottom], ); - const { style: composerKeyboardStyle } = useKeyboardShiftStyle({ - mode: "translate", - }); - - const centeredStyle = useMemo( - () => [animatedStaticStyles.centered, composerKeyboardStyle], - [composerKeyboardStyle], - ); - const agentControlsWithDisabled = useMemo( () => composerState @@ -2272,7 +2262,7 @@ export function NewWorkspaceScreen({ - + {t("newWorkspace.title")} @@ -2338,7 +2328,7 @@ export function NewWorkspaceScreen({ /> )} {errorMessage ? {errorMessage} : null} - + );