mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +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:
+31
-15
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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