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} - + );