Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 3 additions & 32 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ import {
} from "react";
import {
Alert,
AppState,
Keyboard,
Platform,
useWindowDimensions,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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`;
Expand Down
111 changes: 111 additions & 0 deletions apps/mobile/src/features/threads/use-keyboard-resume-guard.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useKeyboardResumeGuard>;

function Probe(props: { visible: boolean; height: number }) {
const state = useKeyboardResumeGuard(props.visible, props.height);
useLayoutEffect(() => {
guard = state;
});
return <div>{state.keyboardStateSuspect ? "resting" : "following keyboard"}</div>;
}

function render(visible = true, height = 320) {
act(() => root.render(<Probe visible={visible} height={height} />));
}

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);
});
});
28 changes: 28 additions & 0 deletions apps/mobile/src/features/threads/use-keyboard-resume-guard.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading