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..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 @@ -23,6 +25,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 +192,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 +217,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 @@ -372,7 +381,19 @@ 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 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() + val imeVisible = + ViewCompat.getRootWindowInsets(this)?.isVisible(WindowInsetsCompat.Type.ime()) == true + if (retainedFocus && imeVisible) { + onTerminalFocus(emptyMap()) + } val inputMethodManager = context.getSystemService( Context.INPUT_METHOD_SERVICE ) as? InputMethodManager 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 new file mode 100644 index 000000000000..a6f8824b23f4 --- /dev/null +++ b/apps/mobile/src/features/keyboard/androidKeyboardRecovery.test.ts @@ -0,0 +1,47 @@ +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"); + }); + + 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..a48242dbea78 --- /dev/null +++ b/apps/mobile/src/features/keyboard/androidKeyboardRecovery.ts @@ -0,0 +1,28 @@ +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, +): 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..7d63e08f4f1d --- /dev/null +++ b/apps/mobile/src/features/keyboard/useAndroidKeyboardRecovery.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useState } from "react"; +import { AppState, Platform } from "react-native"; +import { KeyboardEvents } from "react-native-keyboard-controller"; + +import { + getInitialAndroidKeyboardRecoveryState, + reduceAndroidKeyboardRecovery, + type AndroidKeyboardRecoveryState, +} from "./androidKeyboardRecovery"; + +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", + isAppActive: AppState.currentState === "active", + }), + ); + + 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/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 351082580d63..dbf8bb719d18 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,15 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) height: state.height, isVisible: state.isVisible, })); - const isAccessoryVisible = keyboardState.isVisible && !isAccessoryDismissed; + const { isQuarantined: isKeyboardStateQuarantined, markInputFocused } = + 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(() => { @@ -1269,6 +1277,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) keyboardFocusRequest={keyboardFocusRequest} onInput={handleInput} onResize={handleResize} + onTerminalFocus={markInputFocused} style={{ flex: 1 }} terminalKey={terminalKey} theme={terminalTheme} @@ -1277,6 +1286,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) {isAccessoryVisible ? ( @@ -1325,7 +1335,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) - ) : !keyboardState.isVisible ? ( + ) : !isKeyboardAnimationUsable ? ( ) => void; readonly onResize?: (event: NativeSyntheticEvent) => void; + readonly onTerminalFocus?: () => void; } let cachedNativeTerminalSurfaceView: ComponentType | undefined; diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 94cd242d36e1..0c59c2feb42b 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -106,6 +106,8 @@ import { useIncomingShare } from "../sharing/IncomingShareProvider"; import { selectIncomingShareAttachmentsForServer } from "../sharing/incoming-share-model"; import { appAtomRegistry } from "../../state/atom-registry"; import { serverEnvironment } from "../../state/server"; +import { isAndroidKeyboardAnimationUsable } from "../keyboard/androidKeyboardRecovery"; +import { useAndroidKeyboardRecovery } from "../keyboard/useAndroidKeyboardRecovery"; function NewTaskWorkspaceIcon(props: { readonly workspaceMode: "local" | "worktree"; @@ -165,6 +167,12 @@ export function NewTaskDraftScreen(props: { } = useIncomingShare(); const insets = useSafeAreaInsets(); const isKeyboardVisible = useKeyboardState((state) => 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 }}