diff --git a/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.test.ts b/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.test.ts new file mode 100644 index 000000000..9d22d2b90 --- /dev/null +++ b/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { + beginDrag, + IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, + recordScroll, + releaseDrag, + type ScrollKeyboardDismissEvent, + type ScrollKeyboardDismissGesture, +} from "./model"; + +interface Point { + ts: number; + y: number; + nativeTs?: number | null; +} + +function event(point: Point): ScrollKeyboardDismissEvent { + return { + timeStamp: point.ts, + nativeEvent: { + contentOffset: { y: point.y }, + ...(point.nativeTs === null ? {} : { timestamp: point.nativeTs ?? point.ts }), + }, + }; +} + +function dragThrough(start: Point, points: Point[]): ScrollKeyboardDismissGesture { + return points.reduce( + (gesture, point) => recordScroll(gesture, event(point)), + beginDrag(event(start)), + ); +} + +function shouldDismiss(start: Point, points: Point[], release: Point): boolean { + return releaseDrag(dragThrough(start, points), event(release)).shouldDismiss; +} + +describe("scroll keyboard dismissal", () => { + it("keeps the keyboard up for a slow read-scroll", () => { + expect( + shouldDismiss( + { ts: 1000, y: 0 }, + [ + { ts: 1040, y: 8 }, + { ts: 1080, y: 16 }, + { ts: 1120, y: 24 }, + ], + { ts: 1160, y: 32 }, + ), + ).toBe(false); + }); + + it("dismisses for an upward flick", () => { + expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: 120 }], { ts: 1080, y: 240 })).toBe( + true, + ); + }); + + it("keeps the keyboard up when the inverted list moves toward newer messages", () => { + expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: -120 }], { ts: 1080, y: -240 })).toBe( + false, + ); + }); + + it("keeps a fast-but-decelerating drag below the release threshold", () => { + expect( + shouldDismiss( + { ts: 1000, y: 0 }, + [ + { ts: 1100, y: 500 }, + { ts: 1200, y: 590 }, + { ts: 1270, y: 598 }, + ], + { ts: 1300, y: 600 }, + ), + ).toBe(false); + }); + + it("uses the whole drag when the gesture is too short to sample", () => { + expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1020, y: 60 })).toBe(true); + }); + + it("steps back a sample when release lands immediately after one", () => { + expect( + shouldDismiss( + { ts: 1000, y: 0 }, + [ + { ts: 1040, y: 120 }, + { ts: 1075, y: 225 }, + ], + { ts: 1080, y: 240 }, + ), + ).toBe(true); + }); + + it("keeps the keyboard up when release has no measurable time span", () => { + expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1000, y: 90 })).toBe(false); + }); + + it("ignores scroll events that arrive before the minimum sample span", () => { + expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1029, y: 1000 }], { ts: 1060, y: 1000 })).toBe( + true, + ); + }); + + it("uses native gesture time when delayed callbacks arrive in a burst", () => { + expect( + shouldDismiss( + { ts: 5000, nativeTs: 1000, y: 0 }, + [ + { ts: 5001, nativeTs: 1200, y: 40 }, + { ts: 5002, nativeTs: 1400, y: 80 }, + ], + { ts: 5003, nativeTs: 1600, y: 120 }, + ), + ).toBe(false); + }); + + it("falls back to synthetic event time when native time is absent", () => { + expect( + shouldDismiss({ ts: 1000, nativeTs: null, y: 0 }, [{ ts: 1040, nativeTs: null, y: 120 }], { + ts: 1080, + nativeTs: null, + y: 240, + }), + ).toBe(true); + }); + + it("ignores scroll and release events without a matching drag", () => { + const idle = recordScroll(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, event({ ts: 1000, y: 200 })); + expect(idle).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE); + expect(releaseDrag(idle, event({ ts: 1010, y: 300 })).shouldDismiss).toBe(false); + }); + + it("returns to idle after release", () => { + const release = releaseDrag(beginDrag(event({ ts: 1000, y: 0 })), event({ ts: 1020, y: 60 })); + expect(release.gesture).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE); + }); +}); diff --git a/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts b/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts new file mode 100644 index 000000000..73e0d19a7 --- /dev/null +++ b/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts @@ -0,0 +1,124 @@ +/** + * Pure state machine for the chat history's flick-to-dismiss gesture. + * + * Native keyboardDismissMode cannot express this interaction: "on-drag" + * dismisses on the first pixel, while "interactive" behaves incorrectly on an + * inverted list. We therefore classify the list's existing scroll gesture at + * release instead of introducing a competing pan recognizer. + */ + +const DISMISS_VELOCITY_POINTS_PER_MS = 1.5; +const RELEASE_SAMPLE_MIN_MS = 30; + +/** + * The subset of a native scroll event used by the classifier. React Native's + * type omits `nativeEvent.timestamp`, although iOS and Android both send it. + */ +export interface ScrollKeyboardDismissEvent { + timeStamp: number; + nativeEvent: { + contentOffset: { y: number }; + timestamp?: number; + }; +} + +interface DragSamples { + startTs: number; + startY: number; + sampleTs: number; + sampleY: number; + previousSampleTs: number; + previousSampleY: number; +} + +export type ScrollKeyboardDismissGesture = + | { phase: "idle" } + | { phase: "dragging"; samples: DragSamples }; + +export const IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE: ScrollKeyboardDismissGesture = Object.freeze({ + phase: "idle", +}); + +function resolveEventTimeMs(event: ScrollKeyboardDismissEvent): number { + const nativeTimestamp = event.nativeEvent.timestamp; + if (typeof nativeTimestamp === "number" && nativeTimestamp > 0) { + return nativeTimestamp; + } + return event.timeStamp; +} + +export function beginDrag(event: ScrollKeyboardDismissEvent): ScrollKeyboardDismissGesture { + const timestamp = resolveEventTimeMs(event); + const offsetY = event.nativeEvent.contentOffset.y; + return { + phase: "dragging", + samples: { + startTs: timestamp, + startY: offsetY, + sampleTs: timestamp, + sampleY: offsetY, + previousSampleTs: 0, + previousSampleY: 0, + }, + }; +} + +export function recordScroll( + gesture: ScrollKeyboardDismissGesture, + event: ScrollKeyboardDismissEvent, +): ScrollKeyboardDismissGesture { + if (gesture.phase === "idle") { + return gesture; + } + + const timestamp = resolveEventTimeMs(event); + if (timestamp - gesture.samples.sampleTs < RELEASE_SAMPLE_MIN_MS) { + return gesture; + } + + const { samples } = gesture; + return { + phase: "dragging", + samples: { + startTs: samples.startTs, + startY: samples.startY, + sampleTs: timestamp, + sampleY: event.nativeEvent.contentOffset.y, + previousSampleTs: samples.sampleTs, + previousSampleY: samples.sampleY, + }, + }; +} + +export function releaseDrag( + gesture: ScrollKeyboardDismissGesture, + event: ScrollKeyboardDismissEvent, +): { gesture: ScrollKeyboardDismissGesture; shouldDismiss: boolean } { + if (gesture.phase === "idle") { + return { gesture, shouldDismiss: false }; + } + + const releaseTs = resolveEventTimeMs(event); + const releaseY = event.nativeEvent.contentOffset.y; + const { samples } = gesture; + + // The release event carries the gesture's true endpoint. The final onScroll + // event can still be stale when a short flick lands. + let spanStartTs = samples.startTs; + let spanStartY = samples.startY; + if (releaseTs - samples.sampleTs >= RELEASE_SAMPLE_MIN_MS) { + spanStartTs = samples.sampleTs; + spanStartY = samples.sampleY; + } else if (samples.previousSampleTs > 0) { + spanStartTs = samples.previousSampleTs; + spanStartY = samples.previousSampleY; + } + + const spanDurationMs = releaseTs - spanStartTs; + const releaseVelocity = spanDurationMs > 0 ? (releaseY - spanStartY) / spanDurationMs : 0; + + return { + gesture: IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, + shouldDismiss: releaseVelocity > DISMISS_VELOCITY_POINTS_PER_MS, + }; +} diff --git a/packages/app/src/agent-stream/scroll-keyboard-dismiss/use-scroll-keyboard-dismiss.ts b/packages/app/src/agent-stream/scroll-keyboard-dismiss/use-scroll-keyboard-dismiss.ts new file mode 100644 index 000000000..8632cc2bc --- /dev/null +++ b/packages/app/src/agent-stream/scroll-keyboard-dismiss/use-scroll-keyboard-dismiss.ts @@ -0,0 +1,57 @@ +import { useRef } from "react"; +import { + Keyboard, + TextInput, + type NativeScrollEvent, + type NativeSyntheticEvent, +} from "react-native"; +import { useKeyboardShift } from "@/hooks/keyboard-shift-context"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import { + beginDrag, + IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, + recordScroll, + releaseDrag, +} from "./model"; + +type ScrollEvent = NativeSyntheticEvent; + +/** + * Owns the chat history's flick-to-dismiss behavior. The native stream only + * forwards the FlatList scroll lifecycle; removing this hook and those three + * calls removes the feature completely. + */ +export function useScrollKeyboardDismiss() { + const { shift } = useKeyboardShift(); + const gestureRef = useRef(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE); + + const onScrollBeginDrag = useStableEvent((event: ScrollEvent) => { + gestureRef.current = beginDrag(event); + }); + + const onScroll = useStableEvent((event: ScrollEvent) => { + gestureRef.current = recordScroll(gestureRef.current, event); + }); + + const onScrollEndDrag = useStableEvent((event: ScrollEvent) => { + const release = releaseDrag(gestureRef.current, event); + gestureRef.current = release.gesture; + + // `shift` is the app's UI-thread-derived keyboard inset. Besides avoiding a + // second calculation on JS, this prevents a hardware keyboard's focused + // composer from being blurred when no software keyboard occupies space. + if (!release.shouldDismiss || shift.value <= 0) { + return; + } + + // Keep blur and dismiss paired: this exact sequence was validated on a + // physical Android device to clear both input focus and the IME inset. + const focusedInput = TextInput.State.currentlyFocusedInput(); + if (focusedInput) { + TextInput.State.blurTextInput(focusedInput); + } + Keyboard.dismiss(); + }); + + return { onScroll, onScrollBeginDrag, onScrollEndDrag }; +} diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 6b67096e7..2a38fa26d 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -24,6 +24,7 @@ import type { StreamItem } from "@/types/stream"; import type { Theme } from "@/styles/theme"; import { useStableEvent } from "@/hooks/use-stable-event"; import { useBottomAnchorController } from "./bottom-anchor-controller"; +import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; import { createStreamStrategy, @@ -52,7 +53,6 @@ const historyStartSlotStyle: ViewStyle = { paddingTop: 4, paddingBottom: 8, }; - interface HistoryRowDisplayVariants { regular?: StreamItem; compact?: StreamItem; @@ -110,6 +110,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }); const scrollOffsetYRef = useRef(0); const isUserScrollActiveRef = useRef(false); + const scrollKeyboardDismiss = useScrollKeyboardDismiss(); const userScrollEndFrameIdRef = useRef(null); const programmaticScrollEventBudgetRef = useRef(0); const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false); @@ -335,6 +336,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; const previousOffsetY = scrollOffsetYRef.current; scrollOffsetYRef.current = contentOffset.y; + scrollKeyboardDismiss.onScroll(event); + streamViewportMetricsRef.current = { contentHeight: Math.max(0, contentSize.height), viewportWidth: Math.max(0, layoutMeasurement.width), @@ -365,7 +368,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat } }); - const handleScrollBeginDrag = useStableEvent(() => { + const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent) => { if (!isLoadingOlderHistory) { historyStartPaginationStateRef.current = rearmHistoryStartPagination( historyStartPaginationStateRef.current, @@ -373,6 +376,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat } clearPendingUserScrollEnd(); isUserScrollActiveRef.current = true; + scrollKeyboardDismiss.onScrollBeginDrag(event); bottomAnchorController.beginUserScroll(); evaluateHistoryStart(); }); @@ -381,6 +385,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat // gesture position now because layout may move the viewport in the meantime. const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent) => { const isNearBottom = isScrollEventNearBottom(event); + scrollKeyboardDismiss.onScrollEndDrag(event); + clearPendingUserScrollEnd(); userScrollEndFrameIdRef.current = requestAnimationFrame(() => { userScrollEndFrameIdRef.current = null; diff --git a/packages/app/src/components/ui/autocomplete-popover.tsx b/packages/app/src/components/ui/autocomplete-popover.tsx index 71873a85d..e3b75f31e 100644 --- a/packages/app/src/components/ui/autocomplete-popover.tsx +++ b/packages/app/src/components/ui/autocomplete-popover.tsx @@ -8,7 +8,7 @@ import { measureFloatingPanelPortalHost, useFloatingPanelPortalHostName, } from "@/components/ui/floating-panel-portal"; -import { useKeyboardShift } from "@/hooks/use-keyboard-shift-style"; +import { useKeyboardShift } from "@/hooks/keyboard-shift-context"; import { SPACING } from "@/styles/theme"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; diff --git a/packages/app/src/hooks/keyboard-shift-context.ts b/packages/app/src/hooks/keyboard-shift-context.ts new file mode 100644 index 000000000..a6eeb5372 --- /dev/null +++ b/packages/app/src/hooks/keyboard-shift-context.ts @@ -0,0 +1,18 @@ +import { createContext, useContext } from "react"; +import type { SharedValue } from "react-native-reanimated"; + +export interface KeyboardShiftContextValue { + shift: SharedValue; + bottomInset: SharedValue; +} + +export const KeyboardShiftContext = createContext(null); + +/** Read the app-wide keyboard inset without loading its native provider implementation. */ +export function useKeyboardShift(): KeyboardShiftContextValue { + const context = useContext(KeyboardShiftContext); + if (!context) { + throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider"); + } + return context; +} diff --git a/packages/app/src/hooks/use-keyboard-shift-style.ts b/packages/app/src/hooks/use-keyboard-shift-style.ts index 63799c678..441d73520 100644 --- a/packages/app/src/hooks/use-keyboard-shift-style.ts +++ b/packages/app/src/hooks/use-keyboard-shift-style.ts @@ -1,11 +1,4 @@ -import { - createContext, - createElement, - useContext, - useEffect, - useMemo, - type ReactNode, -} from "react"; +import { createElement, useEffect, useMemo, type ReactNode } from "react"; import { Platform } from "react-native"; import type { ViewStyle } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -23,16 +16,10 @@ import { DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, resolveKeyboardShift, } from "@/hooks/keyboard-shift-policy"; +import { KeyboardShiftContext, useKeyboardShift } from "@/hooks/keyboard-shift-context"; type KeyboardShiftMode = "translate" | "padding"; -interface KeyboardShiftContextValue { - shift: SharedValue; - bottomInset: SharedValue; -} - -const KeyboardShiftContext = createContext(null); - export function KeyboardShiftProvider({ children }: { children: ReactNode }) { const insets = useSafeAreaInsets(); const { height: keyboardHeight, progress: keyboardProgress } = useReanimatedKeyboardAnimation(); @@ -78,14 +65,6 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { return createElement(KeyboardShiftContext.Provider, { value }, children); } -export function useKeyboardShift(): KeyboardShiftContextValue { - const context = useContext(KeyboardShiftContext); - if (!context) { - throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider"); - } - return context; -} - export function useKeyboardShiftStyle(input: { mode: KeyboardShiftMode; enabled?: boolean }): { shift: SharedValue; style: ReturnType>;