Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions app/session/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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<FlatList>(null)
Expand All @@ -85,6 +89,22 @@ export default function SessionScreen() {
const [input, setInput] = useState("")
const [attachments, setAttachments] = useState<Attachment[]>([])
const [showInfo, setShowInfo] = useState(false)
const [keyboardY, setKeyboardY] = useState<number | null>(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,
Expand Down Expand Up @@ -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 */}
<SessionInfo
Expand Down Expand Up @@ -796,7 +816,7 @@ export default function SessionScreen() {
</TouchableOpacity>

<TextInput
style={[s.input, isDark && s.inputDark, speech.listening && s.inputListening]}
style={[s.input, isDark && s.inputDark, speech.listening && s.inputListening, { maxHeight: inputMaxHeight }]}
placeholder={
speech.listening
? t("session.input.placeholderListening")
Expand All @@ -809,6 +829,7 @@ export default function SessionScreen() {
onChangeText={speech.listening ? undefined : setInput}
editable={!speech.listening}
multiline
scrollEnabled
maxLength={10000}
testID="chat-message-input"
/>
Expand Down
16 changes: 9 additions & 7 deletions src/components/chat/QuestionPrompt.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -86,9 +87,8 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) {
<Ionicons name="chatbubble-ellipses-outline" size={18} color="#8b5cf6" />
<Text style={[s.title, isDark && s.textWhite]}>{q.header || t("chat.questionPrompt.headerFallback")}</Text>
</View>
<Text style={[s.question, isDark && s.textWhite]}>{q.question}</Text>

<View style={s.options}>
<ScrollView {...QUESTION_BODY_SCROLL_PROPS} style={s.body} contentContainerStyle={s.options} testID="question-options-scroll">
<Text style={[s.question, isDark && s.textWhite]}>{q.question}</Text>
{q.options.map((opt) => {
const selected = (answers[current] || []).includes(opt.label)
return (
Expand Down Expand Up @@ -129,7 +129,7 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) {
<Text style={[s.optionLabel, { color: "#8b5cf6" }]}>{t("chat.questionPrompt.customAnswerLabel")}</Text>
</TouchableOpacity>
))}
</View>
</ScrollView>

<View style={s.footer}>
<TouchableOpacity onPress={reject}>
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down
49 changes: 49 additions & 0 deletions src/lib/session-layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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 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", () => {
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,
})
})
35 changes: 35 additions & 0 deletions src/lib/session-layout.ts
Original file line number Diff line number Diff line change
@@ -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 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) * 2 + 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