diff --git a/app/_layout.tsx b/app/_layout.tsx index c669aac9..e2f31efd 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -4,6 +4,7 @@ import { StatusBar } from "expo-status-bar" import { useColorScheme, View, ActivityIndicator, AppState } from "react-native" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { GestureHandlerRootView } from "react-native-gesture-handler" +import { KeyboardProvider } from "react-native-keyboard-controller" import { BottomSheetModalProvider } from "@gorhom/bottom-sheet" import { I18nextProvider, useTranslation } from "react-i18next" import i18n from "../src/lib/i18n/config" @@ -120,6 +121,22 @@ function RootLayout() { return () => sub.remove() }, []) + // Android freezes JS timers and usually drops the SSE socket while the app + // sits in the background (Doze/app standby), and the server may close idle + // streams too. On return to foreground, re-establish the stream immediately + // instead of waiting out the reconnect backoff (whose timers were frozen), + // and resync messages/permissions missed while away. reconnectNow() is + // idempotent, so an "active" fired by a transient "inactive" (biometric + // prompt, app switcher) is harmless. + useEffect(() => { + const sub = AppState.addEventListener("change", (next) => { + if (next === "active" && sseStarted.current) { + useEvents.getState().reconnectNow() + } + }) + return () => sub.remove() + }, []) + // Connect/disconnect SSE and load catalog when client changes useEffect(() => { if (client && !sseStarted.current) { @@ -169,6 +186,10 @@ function RootLayout() { return ( + {/* react-native-keyboard-controller: drives keyboard avoidance from + native WindowInsets instead of the legacy RN Keyboard events, which + misreport under Android's mandatory edge-to-edge mode (Expo SDK 54). */} + @@ -226,6 +247,7 @@ function RootLayout() { }} /> + ) } diff --git a/app/connection/add.tsx b/app/connection/add.tsx index b8616086..2f837e03 100644 --- a/app/connection/add.tsx +++ b/app/connection/add.tsx @@ -133,6 +133,10 @@ export default function AddConnectionScreen() { name: name.trim() || t("connection.shared.namePlaceholder"), type: "local", url: serverUrl, + // Carry the transport the pre-flight probe already determined, so the + // save does not probe the same server a second time. + api: result.api, + serverVersion: result.serverVersion, }, password || undefined, ) @@ -209,6 +213,8 @@ export default function AddConnectionScreen() { url: url.trim(), directory: directory.trim() || undefined, username: username.trim() || undefined, + api: result.api, + serverVersion: result.serverVersion, }, password || undefined, ) diff --git a/app/session/[id].tsx b/app/session/[id].tsx index 97f885f0..646c55f0 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -7,7 +7,6 @@ import { TouchableOpacity, StyleSheet, useColorScheme, - KeyboardAvoidingView, Platform, ActivityIndicator, Alert, @@ -20,6 +19,12 @@ import * as ImagePicker from "expo-image-picker" import * as ImageManipulator from "expo-image-manipulator" import * as Clipboard from "expo-clipboard" import type BottomSheet from "@gorhom/bottom-sheet" +// KeyboardAvoidingView from react-native-keyboard-controller (not react-native +// core): core KAV relies on legacy Keyboard events whose metrics misreport +// under Android's mandatory edge-to-edge mode (Expo SDK 54 / RN 0.81), leaving +// the composer hidden behind the keyboard. This library drives avoidance from +// native WindowInsets and is the standard fix for that setup. +import { KeyboardAvoidingView } from "react-native-keyboard-controller" import { MessageBubble, PermissionPrompt, @@ -30,6 +35,7 @@ import { VariantPicker, ImageAttachments, SessionInfo, + MessageTextSheet, type SlashCommand, type Attachment, } from "../../src/components/chat" @@ -39,6 +45,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 { messagePartsToSelectableText } from "../../src/lib/message-text" // --- Builtin slash commands --- const BUILTIN_COMMANDS: SlashCommand[] = [ @@ -85,6 +92,8 @@ export default function SessionScreen() { const [input, setInput] = useState("") const [attachments, setAttachments] = useState([]) const [showInfo, setShowInfo] = useState(false) + // Raw text of the message opened in the full-screen selection sheet + const [textSheet, setTextSheet] = useState(null) const { currentSession, @@ -93,6 +102,7 @@ export default function SessionScreen() { isLoading, loadingMore, hasMore, + error, selectSession, sendMessage, abortSession, @@ -190,10 +200,9 @@ export default function SessionScreen() { [messages, parts, revertMessageID], ) - // Tracks the latest composer text without pulling `input` into - // handleMessageLongPress's deps — kept as a plain ref assignment (not - // state) so the callback below stays referentially stable across - // keystrokes for MessageBubble's custom memo comparator. + // Tracks the latest composer text without pulling `input` into startEditFlow's + // deps — kept as a plain ref assignment (not state) so the callback stays + // referentially stable across keystrokes for MessageBubble's memo comparator. const inputRef = useRef(input) inputRef.current = input @@ -218,38 +227,62 @@ export default function SessionScreen() { ) }, [t]) - // Stable across renders (reads fresh state via getState() rather than - // closing over props) so MessageBubble's custom memo comparator can bail - // safely without risking a stale handler. - const handleMessageLongPress = useCallback((messageID: string) => { - Alert.alert(t("session.alerts.messageActionsTitle"), undefined, [ - { text: t("common.cancel"), style: "cancel" }, - { - text: t("session.actions.editMessage"), - onPress: () => { - const doRevert = async () => { - const result = await useSessions.getState().revertToMessage(messageID) - applyRevertResult(result) - } - // Editing overwrites the composer — don't silently clobber an - // in-progress unsent draft. - if (inputRef.current.trim()) { - Alert.alert( - t("session.alerts.replaceDraftTitle"), - t("session.alerts.replaceDraftMessage"), - [ - { text: t("common.cancel"), style: "cancel" }, - { text: t("session.actions.replace"), style: "destructive", onPress: doRevert }, - ], - { cancelable: false }, - ) - return - } - doRevert() - }, - }, - ]) - }, [applyRevertResult, t]) + // Full-screen selectable view: native text selection can't be trusted inside + // FlatList rows on Android (react-native#46999), so selection happens in a + // plain ScrollView outside the list. Reached from the assistant bubble's + // "Select text" button (a tap — assistant bubbles have no long-press) and + // from the user bubble's long-press action sheet. Covers the whole message: + // assistant text, reasoning and tool calls (name/input/result/error), not + // just text parts. Stable so MessageBubble's memo comparator can bail early. + const openSelectTextSheet = useCallback( + (messageID: string) => { + const messageParts = useSessions.getState().parts[messageID] || [] + setTextSheet( + messagePartsToSelectableText(messageParts, { + thinking: t("session.sheet.thinking"), + tool: t("session.sheet.tool"), + input: t("session.sheet.input"), + result: t("session.sheet.result"), + error: t("session.sheet.error"), + file: t("session.sheet.file"), + }), + ) + }, + [t], + ) + + // Editing a user message reverts the session to it and loads it into the + // composer. Reached from the user bubble's "Edit" button and from its + // long-press action sheet. Stable so MessageBubble's memo comparator bails. + const startEditFlow = useCallback( + (messageID: string) => { + const doRevert = async () => { + const result = await useSessions.getState().revertToMessage(messageID) + applyRevertResult(result) + } + // Editing overwrites the composer — don't silently clobber an + // in-progress unsent draft. + if (inputRef.current.trim()) { + Alert.alert( + t("session.alerts.replaceDraftTitle"), + t("session.alerts.replaceDraftMessage"), + [ + { text: t("common.cancel"), style: "cancel" }, + { text: t("session.actions.replace"), style: "destructive", onPress: doRevert }, + ], + { cancelable: false }, + ) + return + } + doRevert() + }, + [applyRevertResult, t], + ) + + // Message actions live in the bubble header as buttons (Select text / Edit), + // so there is no long-press action sheet here any more — holding a message + // must not pop a menu, because a long-press on the bubble competed with + // horizontal scrolling in code blocks and with text selection. const scrollToBottom = useCallback((animated = true) => { flatListRef.current?.scrollToOffset({ offset: 0, animated }) @@ -593,19 +626,21 @@ export default function SessionScreen() {