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