mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }) {
|
||||
<I18nProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
</I18nProvider>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<RelativeAnchorRect | null>(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(() => {
|
||||
|
||||
23
packages/app/src/hooks/keyboard-shift-policy.ts
Normal file
23
packages/app/src/hooks/keyboard-shift-policy.ts
Normal file
@@ -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);
|
||||
}
|
||||
40
packages/app/src/hooks/use-keyboard-shift-style.test.ts
Normal file
40
packages/app/src/hooks/use-keyboard-shift-style.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<number>;
|
||||
style: ReturnType<typeof useAnimatedStyle<ViewStyle>>;
|
||||
} {
|
||||
bottomInset: SharedValue<number>;
|
||||
}
|
||||
|
||||
const KeyboardShiftContext = createContext<KeyboardShiftContextValue | null>(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<number>;
|
||||
style: ReturnType<typeof useAnimatedStyle<ViewStyle>>;
|
||||
} {
|
||||
const { shift, bottomInset } = useKeyboardShift();
|
||||
const mode = input.mode;
|
||||
const enabled = input.enabled ?? true;
|
||||
|
||||
const style = useAnimatedStyle<ViewStyle>(() => {
|
||||
"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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user