From 82dfb51192316b21f2b3d5abe223caedda8c3ef0 Mon Sep 17 00:00:00 2001 From: Mark Griffin Date: Tue, 25 Aug 2026 17:13:03 +0100 Subject: [PATCH 1/6] fix(mobile): recover keyboard composers after Android resume --- .../keyboard/androidKeyboardRecovery.test.ts | 32 +++++++++++++ .../keyboard/androidKeyboardRecovery.ts | 21 ++++++++ .../keyboard/useAndroidKeyboardRecovery.ts | 44 +++++++++++++++++ .../review/ReviewCommentComposerSheet.tsx | 17 ++++++- .../terminal/ThreadTerminalRouteScreen.tsx | 14 ++++-- .../features/threads/NewTaskDraftScreen.tsx | 15 +++++- .../features/threads/ThreadDetailScreen.tsx | 48 ++++++++----------- 7 files changed, 158 insertions(+), 33 deletions(-) create mode 100644 apps/mobile/src/features/keyboard/androidKeyboardRecovery.test.ts create mode 100644 apps/mobile/src/features/keyboard/androidKeyboardRecovery.ts create mode 100644 apps/mobile/src/features/keyboard/useAndroidKeyboardRecovery.ts diff --git a/apps/mobile/src/features/keyboard/androidKeyboardRecovery.test.ts b/apps/mobile/src/features/keyboard/androidKeyboardRecovery.test.ts new file mode 100644 index 000000000000..3c4292a09888 --- /dev/null +++ b/apps/mobile/src/features/keyboard/androidKeyboardRecovery.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + isAndroidKeyboardAnimationUsable, + reduceAndroidKeyboardRecovery, + type AndroidKeyboardRecoveryState, +} from "./androidKeyboardRecovery"; + +describe("reduceAndroidKeyboardRecovery", () => { + it("quarantines keyboard translation after the app resumes", () => { + expect(reduceAndroidKeyboardRecovery("ready", "resume")).toBe("quarantined"); + }); + + it("keeps the quarantine while the keyboard snapshot is unchanged", () => { + let state: AndroidKeyboardRecoveryState = "ready"; + state = reduceAndroidKeyboardRecovery(state, "resume"); + state = reduceAndroidKeyboardRecovery(state, "resume"); + + expect(state).toBe("quarantined"); + expect( + isAndroidKeyboardAnimationUsable({ + isKeyboardVisible: true, + isQuarantined: state === "quarantined", + }), + ).toBe(false); + }); + + it("releases the quarantine when a live keyboard or input event arrives", () => { + expect(reduceAndroidKeyboardRecovery("quarantined", "keyboard-show")).toBe("ready"); + expect(reduceAndroidKeyboardRecovery("quarantined", "input-focus")).toBe("ready"); + }); +}); diff --git a/apps/mobile/src/features/keyboard/androidKeyboardRecovery.ts b/apps/mobile/src/features/keyboard/androidKeyboardRecovery.ts new file mode 100644 index 000000000000..928db5c6f137 --- /dev/null +++ b/apps/mobile/src/features/keyboard/androidKeyboardRecovery.ts @@ -0,0 +1,21 @@ +export type AndroidKeyboardRecoveryState = "ready" | "quarantined"; + +export type AndroidKeyboardRecoveryEvent = "resume" | "keyboard-show" | "input-focus"; + +export function reduceAndroidKeyboardRecovery( + state: AndroidKeyboardRecoveryState, + event: AndroidKeyboardRecoveryEvent, +): AndroidKeyboardRecoveryState { + if (event === "resume") { + return "quarantined"; + } + + return "ready"; +} + +export function isAndroidKeyboardAnimationUsable(input: { + readonly isKeyboardVisible: boolean; + readonly isQuarantined: boolean; +}): boolean { + return input.isKeyboardVisible && !input.isQuarantined; +} diff --git a/apps/mobile/src/features/keyboard/useAndroidKeyboardRecovery.ts b/apps/mobile/src/features/keyboard/useAndroidKeyboardRecovery.ts new file mode 100644 index 000000000000..625e536b7ed3 --- /dev/null +++ b/apps/mobile/src/features/keyboard/useAndroidKeyboardRecovery.ts @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useState } from "react"; +import { AppState, Platform } from "react-native"; +import { KeyboardEvents } from "react-native-keyboard-controller"; + +import { + reduceAndroidKeyboardRecovery, + type AndroidKeyboardRecoveryState, +} from "./androidKeyboardRecovery"; + +export function useAndroidKeyboardRecovery(): { + readonly isQuarantined: boolean; + readonly markInputFocused: () => void; +} { + const [recoveryState, setRecoveryState] = useState("ready"); + + useEffect(() => { + if (Platform.OS !== "android") { + return; + } + + const appStateSubscription = AppState.addEventListener("change", (state) => { + if (state === "active") { + setRecoveryState((current) => reduceAndroidKeyboardRecovery(current, "resume")); + } + }); + const keyboardShowSubscription = KeyboardEvents.addListener("keyboardWillShow", () => { + setRecoveryState((current) => reduceAndroidKeyboardRecovery(current, "keyboard-show")); + }); + + return () => { + appStateSubscription.remove(); + keyboardShowSubscription.remove(); + }; + }, []); + + const markInputFocused = useCallback(() => { + setRecoveryState((current) => reduceAndroidKeyboardRecovery(current, "input-focus")); + }, []); + + return { + isQuarantined: recoveryState === "quarantined", + markInputFocused, + }; +} diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx index 74ccc8cf0bcb..21e9ca834bdf 100644 --- a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx +++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx @@ -3,7 +3,11 @@ import { TextInputWrapper } from "expo-paste-input"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native"; -import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; +import { + KeyboardAvoidingView, + KeyboardStickyView, + useKeyboardState, +} from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; @@ -17,6 +21,8 @@ import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/ import { useNativePaste } from "../../lib/useNativePaste"; import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; import { appendReviewCommentToDraft } from "../../state/use-thread-composer-state"; +import { isAndroidKeyboardAnimationUsable } from "../keyboard/androidKeyboardRecovery"; +import { useAndroidKeyboardRecovery } from "../keyboard/useAndroidKeyboardRecovery"; import { clearReviewCommentTarget, formatReviewCommentContext, @@ -43,6 +49,13 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp const isAndroid = Platform.OS === "android"; const navigation = useNavigation(); const insets = useSafeAreaInsets(); + const isKeyboardVisible = useKeyboardState((state) => state.isVisible); + const { isQuarantined: isKeyboardStateQuarantined, markInputFocused } = + useAndroidKeyboardRecovery(); + const isKeyboardAnimationUsable = isAndroidKeyboardAnimationUsable({ + isKeyboardVisible, + isQuarantined: isKeyboardStateQuarantined, + }); const { width } = useWindowDimensions(); const { themeAppearance: selectedTheme } = useAppearancePreferences(); const target = useReviewCommentTarget(); @@ -262,6 +275,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp textAlignVertical="top" value={commentText} onChangeText={setCommentText} + onFocus={markInputFocused} className="h-full min-h-0 flex-1 border-0 bg-transparent px-0 py-0 font-sans text-base" /> @@ -308,6 +322,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp {isAndroid && target ? ( diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 351082580d63..2c57cac96a25 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -45,6 +45,8 @@ import { import { useThreadSelection } from "../../state/use-thread-selection"; import { useSelectedThreadDetail } from "../../state/use-thread-detail"; import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; +import { isAndroidKeyboardAnimationUsable } from "../keyboard/androidKeyboardRecovery"; +import { useAndroidKeyboardRecovery } from "../keyboard/useAndroidKeyboardRecovery"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { TerminalSurface } from "./NativeTerminalSurface"; import { getMobileTerminalTheme } from "./terminalTheme"; @@ -507,9 +509,14 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) height: state.height, isVisible: state.isVisible, })); - const isAccessoryVisible = keyboardState.isVisible && !isAccessoryDismissed; + const { isQuarantined: isKeyboardStateQuarantined } = useAndroidKeyboardRecovery(); + const isKeyboardAnimationUsable = isAndroidKeyboardAnimationUsable({ + isKeyboardVisible: keyboardState.isVisible, + isQuarantined: isKeyboardStateQuarantined, + }); + const isAccessoryVisible = isKeyboardAnimationUsable && !isAccessoryDismissed; const terminalBottomInset = - (keyboardState.isVisible ? keyboardState.height : 0) + + (isKeyboardAnimationUsable ? keyboardState.height : 0) + (isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0); useEffect(() => { @@ -1277,6 +1284,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) {isAccessoryVisible ? ( @@ -1325,7 +1333,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) - ) : !keyboardState.isVisible ? ( + ) : !isKeyboardAnimationUsable ? ( state.isVisible); + const { isQuarantined: isKeyboardStateQuarantined, markInputFocused } = + useAndroidKeyboardRecovery(); + const isKeyboardAnimationUsable = isAndroidKeyboardAnimationUsable({ + isKeyboardVisible, + isQuarantined: isKeyboardStateQuarantined, + }); const controlsBottomPadding = Math.max(insets.bottom, 10); const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8); const { projectScopes, selectedProject, selectedProjectKey, setProject } = flow; @@ -1080,7 +1088,10 @@ export function NewTaskDraftScreen(props: { selection={composerMenu.selection} onChangeText={flow.setPrompt} onSelectionChange={composerMenu.onSelectionChange} - onFocus={() => setIsComposerFocused(true)} + onFocus={() => { + markInputFocused(); + setIsComposerFocused(true); + }} onBlur={() => setIsComposerFocused(false)} onPasteImages={(uris) => void handleNativePasteImages(uris)} placeholder="Ask anything…" @@ -1405,6 +1416,7 @@ export function NewTaskDraftScreen(props: { {heroViewport} @@ -1433,6 +1445,7 @@ export function NewTaskDraftScreen(props: { {heroViewport} { - if (Platform.OS !== "android") { - return; - } - const subscription = AppState.addEventListener("change", (state) => { - if (state === "active") { - setKeyboardStateSuspect(true); + // translation on every Android resume instead; only a fresh keyboard show or + // an owned input gaining focus lifts it. A healthy resume sees no visual + // difference (the translation is already zero while the keyboard is closed). + const { isQuarantined: isKeyboardStateQuarantined, markInputFocused } = + useAndroidKeyboardRecovery(); + const isKeyboardAnimationUsable = isAndroidKeyboardAnimationUsable({ + isKeyboardVisible, + isQuarantined: isKeyboardStateQuarantined, + }); + const handleOwnedInputFocusChange = useCallback( + (focused: boolean) => { + if (focused) { + markInputFocused(); } - }); - return () => { - subscription.remove(); - }; - }, []); - useEffect(() => { - setKeyboardStateSuspect(false); - }, [isKeyboardVisible, liveKeyboardHeight]); - const handleOwnedInputFocusChange = useCallback((focused: boolean) => { - if (focused) { - setKeyboardStateSuspect(false); - } - }, []); + }, + [markInputFocused], + ); const windowHeight = useWindowDimensions().height; const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + IOS_NAV_BAR_HEIGHT; const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; @@ -295,7 +287,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // focus-keyed inset is already in place while the composer rides down. // Dictation keeps that focus while the composer switches to its compact pill. const composerBottomInset = ( - Platform.OS === "android" ? isKeyboardVisible : composerExpanded || composerFocused + Platform.OS === "android" ? isKeyboardAnimationUsable : composerExpanded || composerFocused ) ? 0 : Math.max(insets.bottom, 12); @@ -754,7 +746,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // iOS emits a native animated height target on both will-show and // will-hide, so stay subscribed for the full transition. Android // retains its background/resume stale-state quarantine. - enabled={Platform.OS === "ios" || (isKeyboardVisible && !keyboardStateSuspect)} + enabled={Platform.OS === "ios" || isKeyboardAnimationUsable} pointerEvents="box-none" style={{ position: "absolute", bottom: 0, left: 0, right: 0, top: 0 }} offset={{ closed: 0, opened: 0 }} From 7468bed0bf945960ee4c09ad3d48ec818ce80395 Mon Sep 17 00:00:00 2001 From: Mark Griffin Date: Tue, 25 Aug 2026 18:03:11 +0100 Subject: [PATCH 2/6] fix(mobile): release terminal keyboard recovery on focus --- .../src/features/terminal/ThreadTerminalRouteScreen.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 2c57cac96a25..315f05d55760 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -509,7 +509,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) height: state.height, isVisible: state.isVisible, })); - const { isQuarantined: isKeyboardStateQuarantined } = useAndroidKeyboardRecovery(); + const { isQuarantined: isKeyboardStateQuarantined, markInputFocused } = + useAndroidKeyboardRecovery(); const isKeyboardAnimationUsable = isAndroidKeyboardAnimationUsable({ isKeyboardVisible: keyboardState.isVisible, isQuarantined: isKeyboardStateQuarantined, @@ -1090,8 +1091,9 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) }, []); const handleShowKeyboard = useCallback(() => { + markInputFocused(); setKeyboardFocusRequest((current) => current + 1); - }, []); + }, [markInputFocused]); const handleRetryEnvironment = useCallback(() => { if (routeEnvironmentId !== null) { void retryEnvironment(routeEnvironmentId); From 185081b2da078000ab091da6126619dc6fe6cb21 Mon Sep 17 00:00:00 2001 From: Mark Griffin Date: Tue, 25 Aug 2026 18:44:51 +0100 Subject: [PATCH 3/6] fix(mobile): recover terminal focus after Android resume --- .../expo/modules/t3terminal/T3TerminalModule.kt | 2 +- .../expo/modules/t3terminal/T3TerminalView.kt | 7 +++++++ .../t3-terminal/ios/T3TerminalModule.swift | 2 +- .../modules/t3-terminal/ios/T3TerminalView.swift | 2 ++ .../keyboard/androidKeyboardRecovery.test.ts | 15 +++++++++++++++ .../features/keyboard/androidKeyboardRecovery.ts | 7 +++++++ .../keyboard/useAndroidKeyboardRecovery.ts | 14 +++++++++++++- .../features/terminal/NativeTerminalSurface.tsx | 3 +++ .../terminal/ThreadTerminalRouteScreen.tsx | 4 ++-- .../src/features/terminal/nativeTerminalModule.ts | 1 + 10 files changed, 52 insertions(+), 5 deletions(-) diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt index 1631c7fe68a1..87d7fb6cb1e8 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt @@ -54,7 +54,7 @@ class T3TerminalModule : Module() { view.mutedForegroundColorHex = mutedForegroundColor } - Events("onInput", "onResize") + Events("onInput", "onResize", "onTerminalFocus") OnViewDestroys { view: T3TerminalView -> view.cleanup() diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index 88de793a8f7d..bfa753fdf526 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -23,6 +23,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex private val inputView = EditText(context) private val onInput by EventDispatcher() private val onResize by EventDispatcher() + private val onTerminalFocus by EventDispatcher() private var terminalHandle = 0L private var fedBuffer = "" private var cols = 0 @@ -189,6 +190,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex if (isCleanedUp) return isCleanedUp = true inputView.setOnEditorActionListener(null) + inputView.setOnFocusChangeListener(null) terminalCanvas.onScrollRows = null terminalCanvas.onRequestKeyboard = null terminalCanvas.onCellMetricsChanged = null @@ -213,6 +215,11 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS inputView.setPadding(0, 0, 0, 0) + inputView.setOnFocusChangeListener { _, hasFocus -> + if (hasFocus) { + onTerminalFocus(emptyMap()) + } + } inputView.setOnEditorActionListener { _, actionId, event -> val isKeyUp = event?.action == KeyEvent.ACTION_UP val isImeSend = actionId == EditorInfo.IME_ACTION_SEND && !isKeyUp diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift index f68cc6b4a112..8c2d87a04600 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift @@ -51,7 +51,7 @@ public class T3TerminalModule: Module { view.mutedForegroundColorHex = mutedForegroundColor } - Events("onInput", "onResize") + Events("onInput", "onResize", "onTerminalFocus") } } } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index f04db4467fdf..1e31d5dfbc4d 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -215,6 +215,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { let onInput = EventDispatcher() let onResize = EventDispatcher() + let onTerminalFocus = EventDispatcher() var terminalKey: String = "" { didSet { @@ -440,6 +441,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { @objc private func handleInputEditingDidBegin() { + onTerminalFocus() textInputModeDidChange() } diff --git a/apps/mobile/src/features/keyboard/androidKeyboardRecovery.test.ts b/apps/mobile/src/features/keyboard/androidKeyboardRecovery.test.ts index 3c4292a09888..a6f8824b23f4 100644 --- a/apps/mobile/src/features/keyboard/androidKeyboardRecovery.test.ts +++ b/apps/mobile/src/features/keyboard/androidKeyboardRecovery.test.ts @@ -1,11 +1,26 @@ import { describe, expect, it } from "vite-plus/test"; import { + getInitialAndroidKeyboardRecoveryState, isAndroidKeyboardAnimationUsable, reduceAndroidKeyboardRecovery, type AndroidKeyboardRecoveryState, } from "./androidKeyboardRecovery"; +describe("getInitialAndroidKeyboardRecoveryState", () => { + it("quarantines Android surfaces mounted while the app is active", () => { + expect(getInitialAndroidKeyboardRecoveryState({ isAndroid: true, isAppActive: true })).toBe( + "quarantined", + ); + expect(getInitialAndroidKeyboardRecoveryState({ isAndroid: true, isAppActive: false })).toBe( + "ready", + ); + expect(getInitialAndroidKeyboardRecoveryState({ isAndroid: false, isAppActive: true })).toBe( + "ready", + ); + }); +}); + describe("reduceAndroidKeyboardRecovery", () => { it("quarantines keyboard translation after the app resumes", () => { expect(reduceAndroidKeyboardRecovery("ready", "resume")).toBe("quarantined"); diff --git a/apps/mobile/src/features/keyboard/androidKeyboardRecovery.ts b/apps/mobile/src/features/keyboard/androidKeyboardRecovery.ts index 928db5c6f137..a48242dbea78 100644 --- a/apps/mobile/src/features/keyboard/androidKeyboardRecovery.ts +++ b/apps/mobile/src/features/keyboard/androidKeyboardRecovery.ts @@ -2,6 +2,13 @@ export type AndroidKeyboardRecoveryState = "ready" | "quarantined"; export type AndroidKeyboardRecoveryEvent = "resume" | "keyboard-show" | "input-focus"; +export function getInitialAndroidKeyboardRecoveryState(input: { + readonly isAndroid: boolean; + readonly isAppActive: boolean; +}): AndroidKeyboardRecoveryState { + return input.isAndroid && input.isAppActive ? "quarantined" : "ready"; +} + export function reduceAndroidKeyboardRecovery( state: AndroidKeyboardRecoveryState, event: AndroidKeyboardRecoveryEvent, diff --git a/apps/mobile/src/features/keyboard/useAndroidKeyboardRecovery.ts b/apps/mobile/src/features/keyboard/useAndroidKeyboardRecovery.ts index 625e536b7ed3..025aacaad8b7 100644 --- a/apps/mobile/src/features/keyboard/useAndroidKeyboardRecovery.ts +++ b/apps/mobile/src/features/keyboard/useAndroidKeyboardRecovery.ts @@ -3,6 +3,7 @@ import { AppState, Platform } from "react-native"; import { KeyboardEvents } from "react-native-keyboard-controller"; import { + getInitialAndroidKeyboardRecoveryState, reduceAndroidKeyboardRecovery, type AndroidKeyboardRecoveryState, } from "./androidKeyboardRecovery"; @@ -11,7 +12,12 @@ export function useAndroidKeyboardRecovery(): { readonly isQuarantined: boolean; readonly markInputFocused: () => void; } { - const [recoveryState, setRecoveryState] = useState("ready"); + const [recoveryState, setRecoveryState] = useState(() => + getInitialAndroidKeyboardRecoveryState({ + isAndroid: Platform.OS === "android", + isAppActive: AppState.currentState === "active", + }), + ); useEffect(() => { if (Platform.OS !== "android") { @@ -27,6 +33,12 @@ export function useAndroidKeyboardRecovery(): { setRecoveryState((current) => reduceAndroidKeyboardRecovery(current, "keyboard-show")); }); + // The screen may mount after the app has already resumed. In that case + // there is no future active transition for this instance to observe. + if (AppState.currentState === "active") { + setRecoveryState((current) => reduceAndroidKeyboardRecovery(current, "resume")); + } + return () => { appStateSubscription.remove(); keyboardShowSubscription.remove(); diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx index 37dec1fe4562..a047f15d9b91 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -42,6 +42,7 @@ interface TerminalSurfaceProps extends ViewProps { readonly theme?: TerminalTheme; readonly onInput: (data: string) => void; readonly onResize: (size: { readonly cols: number; readonly rows: number }) => void; + readonly onTerminalFocus?: () => void; } function estimateGridSize(input: { @@ -150,6 +151,7 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter props.onInput(`${text}\r`); } }} + onFocus={props.onTerminalFocus} /> ); diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 315f05d55760..dbf8bb719d18 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -1091,9 +1091,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) }, []); const handleShowKeyboard = useCallback(() => { - markInputFocused(); setKeyboardFocusRequest((current) => current + 1); - }, [markInputFocused]); + }, []); const handleRetryEnvironment = useCallback(() => { if (routeEnvironmentId !== null) { void retryEnvironment(routeEnvironmentId); @@ -1278,6 +1277,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) keyboardFocusRequest={keyboardFocusRequest} onInput={handleInput} onResize={handleResize} + onTerminalFocus={markInputFocused} style={{ flex: 1 }} terminalKey={terminalKey} theme={terminalTheme} diff --git a/apps/mobile/src/features/terminal/nativeTerminalModule.ts b/apps/mobile/src/features/terminal/nativeTerminalModule.ts index f6a74595c80e..efb5a75d3425 100644 --- a/apps/mobile/src/features/terminal/nativeTerminalModule.ts +++ b/apps/mobile/src/features/terminal/nativeTerminalModule.ts @@ -34,6 +34,7 @@ export interface NativeTerminalSurfaceProps extends ViewProps { readonly fontSize: number; readonly onInput?: (event: NativeSyntheticEvent) => void; readonly onResize?: (event: NativeSyntheticEvent) => void; + readonly onTerminalFocus?: () => void; } let cachedNativeTerminalSurfaceView: ComponentType | undefined; From cd1fd69f4e96091c82ba8ee89a69d189b276a510 Mon Sep 17 00:00:00 2001 From: Mark Griffin Date: Fri, 4 Sep 2026 14:40:33 +0100 Subject: [PATCH 4/6] fix(mobile): stop recovery mount effect clobbering focus release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A surface mounted while the app is active already starts quarantined via the initial state, so the mount effect re-applying resume was redundant — and when autoFocus released the guard before the effect ran, the effect re-quarantined the surface with no future show event to lift it. --- .../src/features/keyboard/useAndroidKeyboardRecovery.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/keyboard/useAndroidKeyboardRecovery.ts b/apps/mobile/src/features/keyboard/useAndroidKeyboardRecovery.ts index 025aacaad8b7..7d63e08f4f1d 100644 --- a/apps/mobile/src/features/keyboard/useAndroidKeyboardRecovery.ts +++ b/apps/mobile/src/features/keyboard/useAndroidKeyboardRecovery.ts @@ -12,6 +12,9 @@ export function useAndroidKeyboardRecovery(): { readonly isQuarantined: boolean; readonly markInputFocused: () => void; } { + // A surface mounted while the app is already active has no future resume + // transition to observe, so it starts quarantined. Re-applying "resume" in + // the mount effect would clobber an autoFocus release that landed first. const [recoveryState, setRecoveryState] = useState(() => getInitialAndroidKeyboardRecoveryState({ isAndroid: Platform.OS === "android", @@ -33,12 +36,6 @@ export function useAndroidKeyboardRecovery(): { setRecoveryState((current) => reduceAndroidKeyboardRecovery(current, "keyboard-show")); }); - // The screen may mount after the app has already resumed. In that case - // there is no future active transition for this instance to observe. - if (AppState.currentState === "active") { - setRecoveryState((current) => reduceAndroidKeyboardRecovery(current, "resume")); - } - return () => { appStateSubscription.remove(); keyboardShowSubscription.remove(); From 75b725800b4d76c081707d5fc647cae44301af2c Mon Sep 17 00:00:00 2001 From: Mark Griffin Date: Fri, 4 Sep 2026 14:40:35 +0100 Subject: [PATCH 5/6] fix(mobile): release recovery quarantine on retained terminal focus requestFocus on an already-focused EditText emits no focus callback, so a Show keyboard press after an Android resume with retained IME focus never reached onTerminalFocus and the surface stayed quarantined while the real keyboard was up. Emit the focus event when focus was already retained. --- .../main/java/expo/modules/t3terminal/T3TerminalView.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index bfa753fdf526..ce9fcd6be7e7 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -379,7 +379,15 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex } private fun requestKeyboardFocus() { + // requestFocus on an already-focused EditText fires no focus callback, so + // emit it here: an explicit show request means the keyboard stream is live + // again, and the JS recovery quarantine must lift even when the IME was + // retained across an Android resume. + val retainedFocus = inputView.hasFocus() inputView.requestFocus() + if (retainedFocus) { + onTerminalFocus(emptyMap()) + } val inputMethodManager = context.getSystemService( Context.INPUT_METHOD_SERVICE ) as? InputMethodManager From 370d8293fabbcab04e039103532bdaf0c72a295c Mon Sep 17 00:00:00 2001 From: Mark Griffin Date: Fri, 4 Sep 2026 16:46:35 +0100 Subject: [PATCH 6/6] fix(mobile): gate terminal focus release on visible IME insets The retained-focus emit also ran for canvas touches, so a post-resume scroll could clear the recovery quarantine on a stale snapshot. Window insets are ground truth: only synthesize the focus event when the IME is genuinely visible. --- .../java/expo/modules/t3terminal/T3TerminalView.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index ce9fcd6be7e7..d89d8d200664 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -12,6 +12,8 @@ import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.EditText import android.widget.FrameLayout +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import expo.modules.kotlin.AppContext import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.views.ExpoView @@ -380,12 +382,16 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex private fun requestKeyboardFocus() { // requestFocus on an already-focused EditText fires no focus callback, so - // emit it here: an explicit show request means the keyboard stream is live - // again, and the JS recovery quarantine must lift even when the IME was - // retained across an Android resume. + // emit it here when the window insets prove the IME is genuinely visible: + // the keyboard stream is live, and the JS recovery quarantine must lift + // even without a keyboardWillShow. Gating on the insets (not the touch + // that reached this call) keeps a post-resume scroll with a stale + // snapshot from clearing the quarantine. val retainedFocus = inputView.hasFocus() inputView.requestFocus() - if (retainedFocus) { + val imeVisible = + ViewCompat.getRootWindowInsets(this)?.isVisible(WindowInsetsCompat.Type.ime()) == true + if (retainedFocus && imeVisible) { onTerminalFocus(emptyMap()) } val inputMethodManager = context.getSystemService(