From 825eb094a5226148bb424dd9b94c394dfebaf1ce Mon Sep 17 00:00:00 2001 From: duoyi88 Date: Sat, 29 Aug 2026 03:58:10 +0800 Subject: [PATCH 1/2] fix(android): keep composer above keyboard --- app/session/[id].tsx | 8 +++++++- src/lib/keyboard-offset.test.ts | 34 +++++++++++++++++++++++++++++++++ src/lib/keyboard-offset.ts | 11 +++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/lib/keyboard-offset.test.ts create mode 100644 src/lib/keyboard-offset.ts diff --git a/app/session/[id].tsx b/app/session/[id].tsx index 97f885f0..dd3f6a98 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -39,6 +39,7 @@ import { useConnections } from "../../src/stores/connections" import { useAuth } from "../../src/stores/auth" import { useCatalog } from "../../src/stores/catalog" import { useSpeech } from "../../src/lib/speech" +import { keyboardVerticalOffset } from "../../src/lib/keyboard-offset" // --- Builtin slash commands --- const BUILTIN_COMMANDS: SlashCommand[] = [ @@ -605,8 +606,13 @@ export default function SessionScreen() { // bottom toolbar + input were left completely hidden behind the // keyboard (#147). "padding" restores avoidance without depending // on native resize. + // + // The view frame is local to the route content below the native + // header, while keyboard screenY is global. Offset by the complete + // header (safe-area inset + the standard 56 dp Android header) to + // reconcile those coordinate spaces. behavior="padding" - keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0} + keyboardVerticalOffset={keyboardVerticalOffset(Platform.OS, insets.top)} > {/* Session info pulldown */} { + assert.equal(keyboardVerticalOffset("ios", 0), IOS_KEYBOARD_VERTICAL_OFFSET) + assert.equal(keyboardVerticalOffset("ios", 61.29), IOS_KEYBOARD_VERTICAL_OFFSET) +}) + +test("Android offsets by the complete native header height", () => { + assert.equal(keyboardVerticalOffset("android", 61.293), 117.293) +}) + +test("Android includes header content when the top inset is zero or invalid", () => { + assert.equal(keyboardVerticalOffset("android", 0), ANDROID_HEADER_CONTENT_HEIGHT) + assert.equal(keyboardVerticalOffset("android", -20), ANDROID_HEADER_CONTENT_HEIGHT) +}) + +// Pixel 11 Pro / Android 17 regression guard. With only the 61.29 dp safe-area +// inset, the input overlapped the custom Trime IME by 106 px. Adding the +// standard 56 dp header content makes the computed padding equal IME height. +test("Android offset reconciles route-local and screen coordinates", () => { + const routeBottom = 741.65 + const keyboardScreenY = 507.67 + const keyboardHeight = 351.28 + const offset = keyboardVerticalOffset("android", 61.29) + const padding = routeBottom - (keyboardScreenY - offset) + + assert.ok(Math.abs(padding - keyboardHeight) < 0.01, `expected ~${keyboardHeight}, got ${padding}`) +}) diff --git a/src/lib/keyboard-offset.ts b/src/lib/keyboard-offset.ts new file mode 100644 index 00000000..b02fbbab --- /dev/null +++ b/src/lib/keyboard-offset.ts @@ -0,0 +1,11 @@ +// KeyboardAvoidingView computes its padding from a route-local view frame and +// a global keyboard screenY. On Android this route begins below the native +// stack header, so its vertical offset must include the complete header height. + +export const IOS_KEYBOARD_VERTICAL_OFFSET = 90 +export const ANDROID_HEADER_CONTENT_HEIGHT = 56 + +export function keyboardVerticalOffset(platform: string, insetTop: number): number { + if (platform === "ios") return IOS_KEYBOARD_VERTICAL_OFFSET + return Math.max(0, insetTop) + ANDROID_HEADER_CONTENT_HEIGHT +} From 0bb9af0f86b05657b55b1922613a763367f741dd Mon Sep 17 00:00:00 2001 From: duoyi88 Date: Sat, 29 Aug 2026 04:36:07 +0800 Subject: [PATCH 2/2] fix(android): reset composer after keyboard hides --- app/session/[id].tsx | 40 ++++++++++++++++++++++----------- src/lib/keyboard-offset.test.ts | 38 +++++++++++++++---------------- src/lib/keyboard-offset.ts | 16 ++++++++----- 3 files changed, 56 insertions(+), 38 deletions(-) diff --git a/app/session/[id].tsx b/app/session/[id].tsx index dd3f6a98..d43989c6 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -8,6 +8,7 @@ import { StyleSheet, useColorScheme, KeyboardAvoidingView, + Keyboard, Platform, ActivityIndicator, Alert, @@ -39,7 +40,7 @@ import { useConnections } from "../../src/stores/connections" import { useAuth } from "../../src/stores/auth" import { useCatalog } from "../../src/stores/catalog" import { useSpeech } from "../../src/lib/speech" -import { keyboardVerticalOffset } from "../../src/lib/keyboard-offset" +import { keyboardPadding, keyboardVerticalOffset } from "../../src/lib/keyboard-offset" // --- Builtin slash commands --- const BUILTIN_COMMANDS: SlashCommand[] = [ @@ -86,6 +87,20 @@ export default function SessionScreen() { const [input, setInput] = useState("") const [attachments, setAttachments] = useState([]) const [showInfo, setShowInfo] = useState(false) + const [keyboardHeight, setKeyboardHeight] = useState(() => Keyboard.metrics()?.height ?? 0) + + useEffect(() => { + if (Platform.OS !== "android") return + const show = Keyboard.addListener("keyboardDidShow", (event) => setKeyboardHeight(event.endCoordinates.height)) + const hide = Keyboard.addListener("keyboardDidHide", () => setKeyboardHeight(0)) + // Close the render-to-effect race: listeners are attached first, then the + // current native metrics become the source of truth. + setKeyboardHeight(Keyboard.metrics()?.height ?? 0) + return () => { + show.remove() + hide.remove() + } + }, []) const { currentSession, @@ -593,25 +608,24 @@ export default function SessionScreen() { />