mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 02:46:29 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user