From bd189601db96dfa58f9413caaa52f6f106f6ea7c Mon Sep 17 00:00:00 2001 From: Joshua Castaneda Date: Sun, 20 Sep 2026 23:43:49 -0700 Subject: [PATCH 1/2] fix(android): keep IME prompts usable --- app/session/[id].tsx | 25 ++++++++++++-- src/components/chat/QuestionPrompt.tsx | 16 +++++---- src/lib/session-layout.test.ts | 48 ++++++++++++++++++++++++++ src/lib/session-layout.ts | 35 +++++++++++++++++++ 4 files changed, 115 insertions(+), 9 deletions(-) create mode 100644 src/lib/session-layout.test.ts create mode 100644 src/lib/session-layout.ts diff --git a/app/session/[id].tsx b/app/session/[id].tsx index 97f885f0..13ae1fc1 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -11,6 +11,8 @@ import { Platform, ActivityIndicator, Alert, + Keyboard, + useWindowDimensions, } from "react-native" import { useLocalSearchParams, Stack, useRouter, useFocusEffect } from "expo-router" import { Ionicons } from "@expo/vector-icons" @@ -39,6 +41,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 { composerMaxHeight, keyboardVerticalOffset } from "../../src/lib/session-layout" // --- Builtin slash commands --- const BUILTIN_COMMANDS: SlashCommand[] = [ @@ -77,6 +80,7 @@ export default function SessionScreen() { const colorScheme = useColorScheme() const isDark = colorScheme === "dark" const insets = useSafeAreaInsets() + const { height: windowHeight, fontScale } = useWindowDimensions() const { t } = useTranslation() const flatListRef = useRef(null) @@ -85,6 +89,22 @@ export default function SessionScreen() { const [input, setInput] = useState("") const [attachments, setAttachments] = useState([]) const [showInfo, setShowInfo] = useState(false) + const [keyboardY, setKeyboardY] = useState(null) + + useEffect(() => { + const show = Keyboard.addListener(Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow", (event) => { + setKeyboardY(event.endCoordinates.screenY) + }) + const hide = Keyboard.addListener(Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide", () => { + setKeyboardY(null) + }) + return () => { + show.remove() + hide.remove() + } + }, []) + + const inputMaxHeight = composerMaxHeight(Platform.OS, windowHeight, keyboardY, insets.top, fontScale) const { currentSession, @@ -606,7 +626,7 @@ export default function SessionScreen() { // keyboard (#147). "padding" restores avoidance without depending // on native resize. behavior="padding" - keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0} + keyboardVerticalOffset={keyboardVerticalOffset(Platform.OS, insets.top)} > {/* Session info pulldown */} diff --git a/src/components/chat/QuestionPrompt.tsx b/src/components/chat/QuestionPrompt.tsx index d6aaeea4..dd499caf 100644 --- a/src/components/chat/QuestionPrompt.tsx +++ b/src/components/chat/QuestionPrompt.tsx @@ -1,7 +1,8 @@ import { useRef, useState } from "react" -import { View, Text, TextInput, TouchableOpacity, StyleSheet } from "react-native" +import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet } from "react-native" import { Ionicons } from "@expo/vector-icons" import { useTranslation } from "react-i18next" +import { QUESTION_BODY_SCROLL_PROPS } from "../../lib/session-layout" interface QuestionOption { label: string @@ -86,9 +87,8 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) { {q.header || t("chat.questionPrompt.headerFallback")} - {q.question} - - + + {q.question} {q.options.map((opt) => { const selected = (answers[current] || []).includes(opt.label) return ( @@ -129,7 +129,7 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) { {t("chat.questionPrompt.customAnswerLabel")} ))} - + @@ -158,6 +158,7 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) { const s = StyleSheet.create({ card: { + flexShrink: 1, margin: 12, padding: 16, backgroundColor: "#f5f3ff", @@ -169,10 +170,11 @@ const s = StyleSheet.create({ header: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 8 }, title: { fontSize: 15, fontWeight: "600", color: "#6d28d9" }, textWhite: { color: "#ffffff" }, - question: { fontSize: 14, lineHeight: 20, color: "#0a0a0a", marginBottom: 12 }, + body: { flexShrink: 1 }, + question: { fontSize: 14, lineHeight: 20, color: "#0a0a0a" }, metaDark: { color: "#666666" }, - options: { gap: 8 }, + options: { gap: 8, paddingBottom: 1 }, option: { padding: 12, borderRadius: 8, diff --git a/src/lib/session-layout.test.ts b/src/lib/session-layout.test.ts new file mode 100644 index 00000000..09f97f05 --- /dev/null +++ b/src/lib/session-layout.test.ts @@ -0,0 +1,48 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { + COMPOSER_MAX_HEIGHT, + COMPOSER_MIN_HEIGHT, + IOS_KEYBOARD_VERTICAL_OFFSET, + QUESTION_BODY_SCROLL_PROPS, + composerMaxHeight, + keyboardVerticalOffset, +} from "./session-layout.ts" + +test("Android keyboard offset reconciles edge-to-edge screen and window coordinates", () => { + assert.equal(keyboardVerticalOffset("android", 48.857), 48.857) + assert.equal(keyboardVerticalOffset("android", 0), 0) + assert.equal(keyboardVerticalOffset("android", -20), 0) +}) + +test("iOS retains its established keyboard offset", () => { + assert.equal(keyboardVerticalOffset("ios", 0), IOS_KEYBOARD_VERTICAL_OFFSET) + assert.equal(keyboardVerticalOffset("ios", 48.857), IOS_KEYBOARD_VERTICAL_OFFSET) +}) + +test("composer keeps its established cap while the Android IME is hidden", () => { + assert.equal(composerMaxHeight("android", 768, null, 39), COMPOSER_MAX_HEIGHT) + assert.equal(composerMaxHeight("ios", 768, 377, 39), COMPOSER_MAX_HEIGHT) +}) + +test("composer keeps multiple lines visible on compact Android IME geometry", () => { + assert.equal(composerMaxHeight("android", 768, 377, 39), COMPOSER_MIN_HEIGHT) + assert.equal(composerMaxHeight("android", 480, 250, 24), COMPOSER_MIN_HEIGHT) +}) + +test("composer preserves three draft lines at Android accessibility text scaling", () => { + assert.equal(composerMaxHeight("android", 768, 377, 39, 1.5), 110) +}) + +test("composer responds to keyboard resize without exceeding the established cap", () => { + assert.equal(composerMaxHeight("android", 900, 700, 24), COMPOSER_MAX_HEIGHT) + assert.equal(composerMaxHeight("android", 700, 380, 24), 106) +}) + +test("question body uses the only vertical overflow owner and retains navigation taps", () => { + assert.deepEqual(QUESTION_BODY_SCROLL_PROPS, { + keyboardShouldPersistTaps: "handled", + nestedScrollEnabled: true, + showsVerticalScrollIndicator: true, + }) +}) diff --git a/src/lib/session-layout.ts b/src/lib/session-layout.ts new file mode 100644 index 00000000..16375f1f --- /dev/null +++ b/src/lib/session-layout.ts @@ -0,0 +1,35 @@ +export const IOS_KEYBOARD_VERTICAL_OFFSET = 90 +export const COMPOSER_MAX_HEIGHT = 120 +export const COMPOSER_MIN_HEIGHT = 88 +const COMPOSER_LINE_HEIGHT = 20 +const COMPOSER_VERTICAL_PADDING = 20 + +// KeyboardAvoidingView measures its frame in window coordinates while Android +// reports the IME in screen coordinates under edge-to-edge. The top inset +// reconciles those origins without changing iOS's established offset. +export function keyboardVerticalOffset(platform: string, insetTop: number): number { + if (platform === "ios") return IOS_KEYBOARD_VERTICAL_OFFSET + return Math.max(0, insetTop) +} + +export function composerMaxHeight( + platform: string, + windowHeight: number, + keyboardY: number | null, + insetTop: number, + fontScale = 1, +): number { + if (platform !== "android" || keyboardY === null) return COMPOSER_MAX_HEIGHT + + // Keep a three-line editor usable on compact IME-resized windows while + // reserving room for the question/transcript surface and composer controls. + const visibleHeight = Math.max(0, Math.min(windowHeight, keyboardY) - Math.max(0, insetTop)) + const scaledMinimum = Math.ceil(COMPOSER_LINE_HEIGHT * Math.max(1, fontScale) * 3 + COMPOSER_VERTICAL_PADDING) + return Math.min(COMPOSER_MAX_HEIGHT, Math.max(COMPOSER_MIN_HEIGHT, scaledMinimum, visibleHeight - 250)) +} + +export const QUESTION_BODY_SCROLL_PROPS = { + keyboardShouldPersistTaps: "handled", + nestedScrollEnabled: true, + showsVerticalScrollIndicator: true, +} as const From 95b001df8be0a139d18612ee8cedbbbe393d6b7c Mon Sep 17 00:00:00 2001 From: Joshua Castaneda Date: Sun, 20 Sep 2026 23:45:22 -0700 Subject: [PATCH 2/2] test(android): cover scaled composer minimum --- src/lib/session-layout.test.ts | 5 +++-- src/lib/session-layout.ts | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/lib/session-layout.test.ts b/src/lib/session-layout.test.ts index 09f97f05..df323766 100644 --- a/src/lib/session-layout.test.ts +++ b/src/lib/session-layout.test.ts @@ -30,8 +30,9 @@ test("composer keeps multiple lines visible on compact Android IME geometry", () assert.equal(composerMaxHeight("android", 480, 250, 24), COMPOSER_MIN_HEIGHT) }) -test("composer preserves three draft lines at Android accessibility text scaling", () => { - assert.equal(composerMaxHeight("android", 768, 377, 39, 1.5), 110) +test("composer preserves multiple draft lines at Android accessibility text scaling", () => { + assert.equal(composerMaxHeight("android", 768, 377, 39, 1.5), COMPOSER_MIN_HEIGHT) + assert.equal(composerMaxHeight("android", 768, 377, 39, 2), 100) }) test("composer responds to keyboard resize without exceeding the established cap", () => { diff --git a/src/lib/session-layout.ts b/src/lib/session-layout.ts index 16375f1f..108b8b33 100644 --- a/src/lib/session-layout.ts +++ b/src/lib/session-layout.ts @@ -21,10 +21,10 @@ export function composerMaxHeight( ): number { if (platform !== "android" || keyboardY === null) return COMPOSER_MAX_HEIGHT - // Keep a three-line editor usable on compact IME-resized windows while + // Keep multiple scaled draft lines usable on compact IME-resized windows while // reserving room for the question/transcript surface and composer controls. const visibleHeight = Math.max(0, Math.min(windowHeight, keyboardY) - Math.max(0, insetTop)) - const scaledMinimum = Math.ceil(COMPOSER_LINE_HEIGHT * Math.max(1, fontScale) * 3 + COMPOSER_VERTICAL_PADDING) + const scaledMinimum = Math.ceil(COMPOSER_LINE_HEIGHT * Math.max(1, fontScale) * 2 + COMPOSER_VERTICAL_PADDING) return Math.min(COMPOSER_MAX_HEIGHT, Math.max(COMPOSER_MIN_HEIGHT, scaledMinimum, visibleHeight - 250)) }