From 4e899c644a7bc2986c91a9bf27d55ecc8e01dc85 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 18 Jun 2026 12:03:44 +0700 Subject: [PATCH] Keep keyboard offsets tied to visibility Derive app keyboard offsets from one provider-owned shared value so chat content, composer surfaces, and floating panels share the same policy. Treat progress-zero keyboard samples as closed, even if Android reports a stale nonzero height. --- docs/floating-panels.md | 19 ++-- packages/app/src/app/_layout.tsx | 9 +- packages/app/src/components/terminal-pane.tsx | 4 +- .../components/ui/autocomplete-popover.tsx | 21 +--- .../app/src/hooks/keyboard-shift-policy.ts | 23 +++++ .../hooks/use-keyboard-shift-style.test.ts | 40 ++++++++ .../app/src/hooks/use-keyboard-shift-style.ts | 95 +++++++++++-------- 7 files changed, 138 insertions(+), 73 deletions(-) create mode 100644 packages/app/src/hooks/keyboard-shift-policy.ts create mode 100644 packages/app/src/hooks/use-keyboard-shift-style.test.ts diff --git a/docs/floating-panels.md b/docs/floating-panels.md index d36df33a9..48f506f49 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -60,12 +60,11 @@ quietly relying on: Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the `isPaneFocused` prop already exists and flips on pane switches; pass `visible={isYourOwnVisible && isPaneFocused}`. -- **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with - `translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat - content has the same transform applied (`agent-panel.tsx:939`). They move - together because they share the SharedValue. A portal'd popover is outside - the composer tree — it does not get that transform unless you apply it - yourself. +- **Transforms.** `KeyboardShiftProvider` owns the canonical keyboard shift + SharedValue, and `useKeyboardShiftStyle()` only adapts that value into + translate/padding styles. The composer and chat content must both read that + provider-owned value. A portal'd popover is outside the composer tree — it + does not get that transform unless you apply it yourself. - **Layering.** The default root host renders after app content, so it sits above compact sidebars. Content overlays that must sit below sidebars should use the current `FloatingPanelPortalHost`. @@ -86,7 +85,8 @@ with Reanimated worklets the result is not always stable. If the panel cannot stay inside the transformed ancestor, do not try to track the keyboard by re-measuring on every frame. Instead, -**slave the popover's transform to the same SharedValue the composer uses**: +**slave the popover's transform to the same `KeyboardShiftProvider` SharedValue +the composer uses**: 1. Snapshot `openShift = shift.value` at the moment you measure the anchor. 2. Apply `useAnimatedStyle(() => ({ transform: [{ translateY: openShift.value - shift.value }] }))` @@ -95,7 +95,10 @@ the keyboard by re-measuring on every frame. Instead, When `shift` equals `openShift`, the translate is 0 and the popover sits at the measured position. When the keyboard moves afterward, the delta translates the popover by exactly the amount the composer translates. They move in -lockstep, no re-measurement needed. +lockstep, no re-measurement needed. Do not call +`useReanimatedKeyboardAnimation()` directly for app UI offset policy; Android +can briefly report a stale nonzero height with closed progress, and the shared +provider is where that is normalized. Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only to refresh the snapshot if the keyboard was mid-transition when the popover diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index d361015e7..b04fa21a5 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -66,6 +66,7 @@ import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-acti import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action"; import { useFaviconStatus } from "@/hooks/use-favicon-status"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; +import { KeyboardShiftProvider } from "@/hooks/use-keyboard-shift-style"; import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock"; import { useOpenProject } from "@/hooks/use-open-project"; import { useAppSettings } from "@/hooks/use-settings"; @@ -945,9 +946,11 @@ function RootProviders({ children }: { children: ReactNode }) { - - {children} - + + + {children} + + diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 06bdcdf58..42a13710a 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -337,14 +337,14 @@ export function TerminalPane({ }, [clearKeyboardRefitTimeouts]); useAnimatedReaction( - () => keyboardShift.value > 0, + () => isMobile && keyboardShift.value > 0, (next, prev) => { if (next === prev) { return; } runOnJS(pulseKeyboardRefits)(); }, - [pulseKeyboardRefits], + [isMobile, pulseKeyboardRefits], ); useEffect(() => { diff --git a/packages/app/src/components/ui/autocomplete-popover.tsx b/packages/app/src/components/ui/autocomplete-popover.tsx index f83d82130..71873a85d 100644 --- a/packages/app/src/components/ui/autocomplete-popover.tsx +++ b/packages/app/src/components/ui/autocomplete-popover.tsx @@ -1,19 +1,14 @@ import { useEffect, useMemo, useState, type ReactElement, type RefObject } from "react"; import { Keyboard, View, useWindowDimensions } from "react-native"; import { Portal } from "@gorhom/portal"; -import Animated, { - useAnimatedStyle, - useDerivedValue, - useSharedValue, -} from "react-native-reanimated"; -import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated"; import { StyleSheet } from "react-native-unistyles"; import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete"; import { measureFloatingPanelPortalHost, useFloatingPanelPortalHostName, } from "@/components/ui/floating-panel-portal"; +import { useKeyboardShift } from "@/hooks/use-keyboard-shift-style"; import { SPACING } from "@/styles/theme"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; @@ -68,18 +63,8 @@ export function AutocompletePopover({ // React Compiler memoizes effect captures by reading SharedValue.value during render. const [relativeAnchorRect, setRelativeAnchorRect] = useState(null); const windowDimensions = useWindowDimensions(); - const insets = useSafeAreaInsets(); const portalHostName = useFloatingPanelPortalHostName(); - - const { height: rawKeyboardHeight } = useReanimatedKeyboardAnimation(); - const bottomInsetSV = useSharedValue(insets.bottom); - useEffect(() => { - bottomInsetSV.value = insets.bottom; - }, [bottomInsetSV, insets.bottom]); - - const shift = useDerivedValue(() => - Math.max(0, Math.abs(rawKeyboardHeight.value) - bottomInsetSV.value), - ); + const { shift } = useKeyboardShift(); const openShift = useSharedValue(0); useEffect(() => { diff --git a/packages/app/src/hooks/keyboard-shift-policy.ts b/packages/app/src/hooks/keyboard-shift-policy.ts new file mode 100644 index 000000000..546bffa66 --- /dev/null +++ b/packages/app/src/hooks/keyboard-shift-policy.ts @@ -0,0 +1,23 @@ +export const DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT = 120; + +export function resolveKeyboardShift(input: { + rawKeyboardHeight: number; + keyboardProgress: number; + bottomInset: number; + isIos: boolean; + iosMinHeight: number; +}): number { + "worklet"; + + if (!(input.keyboardProgress > 0) || !(input.rawKeyboardHeight > 0)) { + return 0; + } + + // iOS can report a small accessory/prediction bar height during touch focus. + // Treat that as non-keyboard so layouts don't "bounce" while interacting. + if (input.isIos && input.rawKeyboardHeight < input.iosMinHeight) { + return 0; + } + + return Math.max(0, input.rawKeyboardHeight - input.bottomInset); +} diff --git a/packages/app/src/hooks/use-keyboard-shift-style.test.ts b/packages/app/src/hooks/use-keyboard-shift-style.test.ts new file mode 100644 index 000000000..0f254d59c --- /dev/null +++ b/packages/app/src/hooks/use-keyboard-shift-style.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { resolveKeyboardShift } from "./keyboard-shift-policy"; + +describe("resolveKeyboardShift", () => { + it("keeps the existing open-keyboard offset behavior", () => { + expect( + resolveKeyboardShift({ + rawKeyboardHeight: 320, + keyboardProgress: 1, + bottomInset: 24, + isIos: false, + iosMinHeight: 120, + }), + ).toBe(296); + }); + + it("treats progress zero as closed even when Android reports a stale height", () => { + expect( + resolveKeyboardShift({ + rawKeyboardHeight: 320, + keyboardProgress: 0, + bottomInset: 24, + isIos: false, + iosMinHeight: 120, + }), + ).toBe(0); + }); + + it("still ignores small iOS accessory bar reports", () => { + expect( + resolveKeyboardShift({ + rawKeyboardHeight: 80, + keyboardProgress: 1, + bottomInset: 0, + isIos: true, + iosMinHeight: 120, + }), + ).toBe(0); + }); +}); diff --git a/packages/app/src/hooks/use-keyboard-shift-style.ts b/packages/app/src/hooks/use-keyboard-shift-style.ts index bff828d37..a07ce56c3 100644 --- a/packages/app/src/hooks/use-keyboard-shift-style.ts +++ b/packages/app/src/hooks/use-keyboard-shift-style.ts @@ -1,4 +1,11 @@ -import { useEffect } from "react"; +import { + createContext, + createElement, + useContext, + useEffect, + useMemo, + type ReactNode, +} from "react"; import { Platform } from "react-native"; import type { ViewStyle } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -9,47 +16,25 @@ import { useSharedValue, type SharedValue, } from "react-native-reanimated"; - -const DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT = 120; - -function resolveKeyboardShift(input: { - rawKeyboardHeight: number; - bottomInset: number; - isIos: boolean; - iosMinHeight: number; - enabled: boolean; -}): number { - "worklet"; - - if (!input.enabled) { - return 0; - } - - // iOS can report a small accessory/prediction bar height during touch focus. - // Treat that as non-keyboard so layouts don't "bounce" while interacting. - if (input.isIos && input.rawKeyboardHeight < input.iosMinHeight) { - return 0; - } - - return Math.max(0, input.rawKeyboardHeight - input.bottomInset); -} +import { + DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, + resolveKeyboardShift, +} from "@/hooks/keyboard-shift-policy"; type KeyboardShiftMode = "translate" | "padding"; -export function useKeyboardShiftStyle(input: { - mode: KeyboardShiftMode; - enabled?: boolean; - iosMinHeight?: number; -}): { +interface KeyboardShiftContextValue { shift: SharedValue; - style: ReturnType>; -} { + bottomInset: SharedValue; +} + +const KeyboardShiftContext = createContext(null); + +export function KeyboardShiftProvider({ children }: { children: ReactNode }) { const insets = useSafeAreaInsets(); - const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); + const { height: keyboardHeight, progress: keyboardProgress } = useReanimatedKeyboardAnimation(); const bottomInset = useSharedValue(insets.bottom); - const enabled = input.enabled ?? true; const isIos = Platform.OS === "ios"; - const iosMinHeight = input.iosMinHeight ?? DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT; useEffect(() => { bottomInset.value = insets.bottom; @@ -57,19 +42,45 @@ export function useKeyboardShiftStyle(input: { const shift = useDerivedValue(() => { "worklet"; - const rawKeyboardHeight = Math.abs(keyboardHeight.value); return resolveKeyboardShift({ - rawKeyboardHeight, + rawKeyboardHeight: Math.abs(keyboardHeight.value), + keyboardProgress: keyboardProgress.value, bottomInset: bottomInset.value, isIos, - iosMinHeight, - enabled, + iosMinHeight: DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, }); }); + const value = useMemo( + () => ({ + shift, + bottomInset, + }), + [bottomInset, shift], + ); + + return createElement(KeyboardShiftContext.Provider, { value }, children); +} + +export function useKeyboardShift(): KeyboardShiftContextValue { + const context = useContext(KeyboardShiftContext); + if (!context) { + throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider"); + } + return context; +} + +export function useKeyboardShiftStyle(input: { mode: KeyboardShiftMode; enabled?: boolean }): { + shift: SharedValue; + style: ReturnType>; +} { + const { shift, bottomInset } = useKeyboardShift(); + const mode = input.mode; + const enabled = input.enabled ?? true; + const style = useAnimatedStyle(() => { "worklet"; - if (input.mode === "padding") { + if (mode === "padding") { if (!enabled) { return { paddingBottom: 0 }; } @@ -77,8 +88,8 @@ export function useKeyboardShiftStyle(input: { return { paddingBottom: bottomInset.value + shift.value }; } - return { transform: [{ translateY: -shift.value }] }; - }, [input.mode]); + return { transform: [{ translateY: enabled ? -shift.value : 0 }] }; + }, [enabled, mode]); return { shift, style }; }