diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 79990884085d1..589128c6ce84a 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -58,7 +58,6 @@ import { } from "react"; import { Alert, - AppState, Keyboard, Platform, useWindowDimensions, @@ -144,6 +143,7 @@ import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit"; import { useThreadQueuedCount } from "./ThreadQueueControl"; import type { ThreadContentPresentation } from "./threadContentPresentation"; import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; +import { useKeyboardResumeGuard } from "./use-keyboard-resume-guard"; import { useGlobalVoiceInput } from "../voice-input/VoiceInputProvider"; export interface ThreadDetailScreenProps { @@ -356,37 +356,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const insets = useSafeAreaInsets(); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const liveKeyboardHeight = useKeyboardState((state) => state.height); - // Android can swallow the IME hide callbacks when the app is backgrounded - // mid keyboard-hide (the reported repro: send — which blurs and starts the - // hide — then Home within a second). The keyboard library's height AND - // visibility then stay frozen open, so gating the sticky translation on - // visibility alone still strands the composer after resume. Quarantine the - // translation on every Android resume instead; any sign of a live keyboard - // stream — an owned input gaining focus, or any visibility/height movement — - // lifts it. A healthy resume sees no visual difference (the translation is - // already zero while the keyboard is closed). - const [keyboardStateSuspect, setKeyboardStateSuspect] = useState(false); - useEffect(() => { - if (Platform.OS !== "android") { - return; - } - const subscription = AppState.addEventListener("change", (state) => { - if (state === "active") { - setKeyboardStateSuspect(true); - } - }); - return () => { - subscription.remove(); - }; - }, []); - useEffect(() => { - setKeyboardStateSuspect(false); - }, [isKeyboardVisible, liveKeyboardHeight]); - const handleOwnedInputFocusChange = useCallback((focused: boolean) => { - if (focused) { - setKeyboardStateSuspect(false); - } - }, []); + const { keyboardStateSuspect, onInputFocusChange: handleOwnedInputFocusChange } = + useKeyboardResumeGuard(isKeyboardVisible, liveKeyboardHeight); const windowHeight = useWindowDimensions().height; const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44; const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; diff --git a/apps/mobile/src/features/threads/use-keyboard-resume-guard.test.tsx b/apps/mobile/src/features/threads/use-keyboard-resume-guard.test.tsx new file mode 100644 index 0000000000000..2bb09937c1262 --- /dev/null +++ b/apps/mobile/src/features/threads/use-keyboard-resume-guard.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment jsdom +import { act, useLayoutEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const harness = vi.hoisted(() => ({ + platform: "android", + listeners: new Set<(state: string) => void>(), +})); + +vi.mock("react-native", () => ({ + Platform: { + get OS() { + return harness.platform; + }, + }, + AppState: { + addEventListener: (_event: "change", listener: (state: string) => void) => { + harness.listeners.add(listener); + return { remove: () => harness.listeners.delete(listener) }; + }, + }, +})); + +import { useKeyboardResumeGuard } from "./use-keyboard-resume-guard"; + +let root: Root; +let guard: ReturnType; + +function Probe(props: { visible: boolean; height: number }) { + const state = useKeyboardResumeGuard(props.visible, props.height); + useLayoutEffect(() => { + guard = state; + }); + return
{state.keyboardStateSuspect ? "resting" : "following keyboard"}
; +} + +function render(visible = true, height = 320) { + act(() => root.render()); +} + +function appState(state: string) { + act(() => { + for (const listener of harness.listeners) listener(state); + }); +} + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + harness.platform = "android"; + root = createRoot(document.createElement("div")); +}); + +afterEach(() => { + act(() => root.unmount()); + expect(harness.listeners.size).toBe(0); +}); + +describe("useKeyboardResumeGuard", () => { + it("keeps following an open keyboard after switching apps without a new focus or height event", () => { + render(); + act(() => guard.onInputFocusChange(true)); + appState("background"); + appState("active"); + expect(guard.keyboardStateSuspect).toBe(false); + }); + + it("ignores stale open-keyboard state after sending and backgrounding during dismissal", () => { + render(); + act(() => guard.onInputFocusChange(true)); + act(() => guard.onInputFocusChange(false)); + appState("background"); + appState("active"); + expect(guard.keyboardStateSuspect).toBe(true); + render(); + expect(guard.keyboardStateSuspect).toBe(true); + }); + + it("resumes following the keyboard when an owned input is focused again", () => { + render(); + appState("background"); + appState("active"); + expect(guard.keyboardStateSuspect).toBe(true); + act(() => guard.onInputFocusChange(true)); + expect(guard.keyboardStateSuspect).toBe(false); + }); + + it("accepts a keyboard height update after returning with no focused input", () => { + render(); + appState("background"); + appState("active"); + render(true, 340); + expect(guard.keyboardStateSuspect).toBe(false); + }); + + it("accepts a keyboard hide event even if its reported height did not change", () => { + render(); + appState("background"); + appState("active"); + render(false, 320); + expect(guard.keyboardStateSuspect).toBe(false); + }); + + it("leaves iOS keyboard transitions enabled across app switches", () => { + harness.platform = "ios"; + render(); + appState("background"); + appState("active"); + expect(guard.keyboardStateSuspect).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/threads/use-keyboard-resume-guard.ts b/apps/mobile/src/features/threads/use-keyboard-resume-guard.ts new file mode 100644 index 0000000000000..bb8c3a7f0b421 --- /dev/null +++ b/apps/mobile/src/features/threads/use-keyboard-resume-guard.ts @@ -0,0 +1,28 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { AppState, Platform } from "react-native"; + +export function useKeyboardResumeGuard(isKeyboardVisible: boolean, keyboardHeight: number) { + const ownedInputFocused = useRef(false); + const [keyboardStateSuspect, setKeyboardStateSuspect] = useState(false); + useEffect(() => { + if (Platform.OS !== "android") return; + const subscription = AppState.addEventListener("change", (state) => { + // A focused input keeps its keyboard across app switches without another + // focus or height event. Only distrust a resumed keyboard after blur: + // Android can lose the hide event if backgrounded during dismissal. + if (state === "active") setKeyboardStateSuspect(!ownedInputFocused.current); + }); + return () => subscription.remove(); + }, []); + + useEffect(() => { + setKeyboardStateSuspect(false); + }, [isKeyboardVisible, keyboardHeight]); + + const onInputFocusChange = useCallback((focused: boolean) => { + ownedInputFocused.current = focused; + if (focused) setKeyboardStateSuspect(false); + }, []); + + return { keyboardStateSuspect, onInputFocusChange }; +}