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
This commit is contained in:
Mohamed Boudra
2026-09-03 20:27:19 +02:00
committed by GitHub
parent 53c9607479
commit bb676ef086
19 changed files with 343 additions and 159 deletions
+31 -15
View File
@@ -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:
@@ -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<number | null>(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 (
<FlatList
{...listInsetProps}
ref={flatListRef}
data={historyRows}
renderItem={renderItem}
@@ -569,7 +580,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={liveHeaderContent ?? undefined}
ListFooterComponent={historyFooterContent ?? undefined}
contentContainerStyle={baseListContentContainerStyle}
contentContainerStyle={listContentContainerStyle}
style={listStyle}
onLayout={handleListLayout}
onScroll={handleScroll}
@@ -476,7 +476,7 @@ export function AdaptiveModalSheet({
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const insets = useSafeAreaInsets();
const isKeyboardVisible = useKeyboardVisibility();
const isKeyboardVisible = useKeyboardVisibility(visible);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const compactSafeAreaPadding = useMemo(
() =>
@@ -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<string | null>(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 (
<Animated.View style={containerStyle}>
<KeyboardTranslateView style={containerStyle}>
<View style={styles.inputAreaContainer}>
<View style={styles.inputAreaContent}>
<View style={styles.calloutStack}>
@@ -67,7 +64,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
</View>
</View>
</View>
</Animated.View>
</KeyboardTranslateView>
);
}
@@ -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 (
<Animated.View style={[style, keyboardPaddingStyle]} {...props}>
<KeyboardTranslateView style={style} {...props}>
{children}
</Animated.View>
</KeyboardTranslateView>
);
}
@@ -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 (
<Animated.View style={[style, keyboardStyle]} {...props}>
{children}
</Animated.View>
);
}
@@ -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 (
<Animated.View style={[style, keyboardStyle]} {...props}>
{children}
</Animated.View>
);
}
@@ -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 <View {...props}>{children}</View>;
}
@@ -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;
@@ -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({
)}
</View>
<ReanimatedAnimated.View style={inputAreaWrapperStyle} onLayout={onInputAreaLayout}>
<KeyboardTranslateView style={inputAreaWrapperStyle} onLayout={onInputAreaLayout}>
{importPillPress ? (
<View style={styles.importPillRow}>
<View style={styles.importPillContent}>
@@ -711,7 +702,7 @@ export function WorkspaceDraftAgentTab({
agentControls={composerAgentControls}
isCompactLayout={isCompactComposerLayout}
/>
</ReanimatedAnimated.View>
</KeyboardTranslateView>
</FileDropZone>
);
}
+6 -13
View File
@@ -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}
/>
<Animated.View style={composerContainerStyle}>
<KeyboardTranslateView
style={animatedStaticStyles.container}
enabled={!externalKeyboardShift}
>
<AttachmentLightbox source={lightboxSource} onClose={handleLightboxClose} />
{/* Input area */}
<View style={inputAreaContainerStyle}>
@@ -2413,7 +2406,7 @@ function ComposerContentImpl({
</View>
</View>
</View>
</Animated.View>
</KeyboardTranslateView>
</>
);
}
@@ -3,10 +3,12 @@ import type { SharedValue } from "react-native-reanimated";
export interface KeyboardShiftContextValue {
shift: SharedValue<number>;
isMoving: SharedValue<boolean>;
bottomInset: SharedValue<number>;
}
export const KeyboardShiftContext = createContext<KeyboardShiftContextValue | null>(null);
export const SettledKeyboardShiftContext = createContext<number | null>(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;
}
@@ -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";
@@ -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(
@@ -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 }): {
@@ -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,
);
}
@@ -1,3 +1,3 @@
export function useKeyboardVisibility(): boolean {
export function useKeyboardVisibility(_enabled = true): boolean {
return false;
}
+3 -1
View File
@@ -1390,7 +1390,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
setText={agentInputDraft.replaceText}
onRewindComplete={handleRewindComplete}
>
<View style={styles.root}>
<View style={styles.root} collapsable={false}>
<DockedChatSurface disabled={isArchivingCurrentAgent}>
{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,
@@ -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({
<ScreenHeader left={screenHeaderLeft} borderless />
<View style={contentStyle}>
<TitlebarDragRegion />
<ReanimatedAnimated.View style={centeredStyle}>
<KeyboardTranslateView style={animatedStaticStyles.centered}>
<View style={styles.composerTitleContainer}>
<Text style={styles.composerTitle}>{t("newWorkspace.title")}</Text>
</View>
@@ -2338,7 +2328,7 @@ export function NewWorkspaceScreen({
/>
)}
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</ReanimatedAnimated.View>
</KeyboardTranslateView>
</View>
</FileDropZone>
);