From 2b4ca2b3826df2c2f0cc4b2c351475e743256926 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 16 Oct 2026 16:56:11 -0700 Subject: [PATCH 1/2] fix(android): a composer focused through an app switch stays above the keyboard On resume, Android quarantines the keyboard state because the IME hide callbacks can be swallowed when the app is backgrounded mid-hide. If the composer kept focus through the switch, Android restores the keyboard unchanged and no keyboard event arrives to lift the quarantine, so the composer laid out under the keyboard. Skip the quarantine when an owned input is still focused; the stranded case always starts with a blur. Co-authored-by: Pixel Perfect Co-Authored-By: Claude Fable 5.1 --- apps/mobile/src/features/threads/ThreadDetailScreen.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index d9da5f506656c..2a24bdd9fdd3f 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -363,15 +363,18 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // 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. + // translation on visibility alone still strands the composer after resume. An input that kept + // focus through the switch is exempt: Android restores its keyboard unchanged, so no keyboard + // or focus event would ever lift the quarantine. The stranded case always starts with a blur. const [keyboardStateSuspect, setKeyboardStateSuspect] = useState(false); + const ownedInputFocusedRef = useRef(false); useEffect(() => { if (Platform.OS !== "android") { return; } const subscription = AppState.addEventListener("change", (state) => { if (state === "active") { - setKeyboardStateSuspect(true); + setKeyboardStateSuspect(!ownedInputFocusedRef.current); } }); return () => { @@ -382,6 +385,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread setKeyboardStateSuspect(false); }, [isKeyboardVisible, liveKeyboardHeight]); const handleOwnedInputFocusChange = useCallback((focused: boolean) => { + ownedInputFocusedRef.current = focused; if (focused) { setKeyboardStateSuspect(false); } From 5ab1bdd1626e9d96e1f391828d21204be31fb245 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 16 Oct 2026 20:47:27 -0700 Subject: [PATCH 2/2] fix(android): a question input that unmounts while focused releases the keyboard A focused question input that unmounts (collapsed, submitted, or resolved elsewhere) never delivers its onBlur, so the screen kept treating the keyboard as owned and skipped the resume quarantine. The input now releases focus on unmount. The quarantine comment no longer claims every stranded case starts with a blur: Back hides the keyboard and keeps focus. Co-authored-by: Pixel Perfect Co-Authored-By: Claude Opus 5.5 --- .../features/threads/QuestionAttachments.tsx | 20 ++++++++++++++++--- .../features/threads/ThreadDetailScreen.tsx | 3 ++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/QuestionAttachments.tsx b/apps/mobile/src/features/threads/QuestionAttachments.tsx index d915166e5aef7..91469aec24e58 100644 --- a/apps/mobile/src/features/threads/QuestionAttachments.tsx +++ b/apps/mobile/src/features/threads/QuestionAttachments.tsx @@ -6,7 +6,7 @@ import { convertPastedImagesToAttachments } from "../../lib/composerImages"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type UserInputQuestion } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { View } from "react-native"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; import { ComposerAttachmentButton } from "./ComposerAttachmentButton"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; import { pickComposerFiles, pickComposerMedia } from "../../lib/composerImages"; @@ -49,6 +49,14 @@ export function QuestionAttachments(props: { props.requestId, props.question.id, ]); + // A focused input that unmounts never delivers its onBlur, so release focus here or + // the screen would keep treating the keyboard as owned by a removed input. + const focusedRef = useRef(false); + const onInputFocusChange = props.onInputFocusChange; + const releaseFocus = useEffectEvent(() => { + if (focusedRef.current) onInputFocusChange?.(false); + }); + useEffect(() => () => releaseFocus(), []); const pickerScope = useRef<{ key: string; active: boolean } | null>(null); useEffect(() => { const scope = { key: scopeKey, active: true }; @@ -197,8 +205,14 @@ export function QuestionAttachments(props: { submitBehavior={props.question.initialAnswer !== undefined ? "newline" : undefined} editable={!props.disabled} onChangeText={props.onChangeText} - onFocus={() => props.onInputFocusChange?.(true)} - onBlur={() => props.onInputFocusChange?.(false)} + onFocus={() => { + focusedRef.current = true; + onInputFocusChange?.(true); + }} + onBlur={() => { + focusedRef.current = false; + onInputFocusChange?.(false); + }} placeholder="Or type a custom answer" className="min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground" /> diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 2a24bdd9fdd3f..48f69837d6707 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -365,7 +365,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // keyboard library's height AND visibility then stay frozen open, so gating the sticky // translation on visibility alone still strands the composer after resume. An input that kept // focus through the switch is exempt: Android restores its keyboard unchanged, so no keyboard - // or focus event would ever lift the quarantine. The stranded case always starts with a blur. + // or focus event would ever lift the quarantine. Hiding the keyboard with Back keeps focus, so + // Back then Home mid-hide takes the exemption and can still strand the composer. const [keyboardStateSuspect, setKeyboardStateSuspect] = useState(false); const ownedInputFocusedRef = useRef(false); useEffect(() => {