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
22 changes: 22 additions & 0 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -169,6 +186,10 @@ function RootLayout() {

return (
<ErrorBoundary>
{/* 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). */}
<KeyboardProvider>
<I18nextProvider i18n={i18n}>
<GestureHandlerRootView style={{ flex: 1 }}>
<BottomSheetModalProvider>
Expand Down Expand Up @@ -226,6 +247,7 @@ function RootLayout() {
}}
/>
</I18nextProvider>
</KeyboardProvider>
</ErrorBoundary>
)
}
Expand Down
6 changes: 6 additions & 0 deletions app/connection/add.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand Down
164 changes: 114 additions & 50 deletions app/session/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
TouchableOpacity,
StyleSheet,
useColorScheme,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
Alert,
Expand All @@ -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,
Expand All @@ -30,6 +35,7 @@ import {
VariantPicker,
ImageAttachments,
SessionInfo,
MessageTextSheet,
type SlashCommand,
type Attachment,
} from "../../src/components/chat"
Expand All @@ -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[] = [
Expand Down Expand Up @@ -85,6 +92,8 @@ export default function SessionScreen() {
const [input, setInput] = useState("")
const [attachments, setAttachments] = useState<Attachment[]>([])
const [showInfo, setShowInfo] = useState(false)
// Raw text of the message opened in the full-screen selection sheet
const [textSheet, setTextSheet] = useState<string | null>(null)

const {
currentSession,
Expand All @@ -93,6 +102,7 @@ export default function SessionScreen() {
isLoading,
loadingMore,
hasMore,
error,
selectSession,
sendMessage,
abortSession,
Expand Down Expand Up @@ -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

Expand All @@ -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 })
Expand Down Expand Up @@ -593,19 +626,21 @@ export default function SessionScreen() {

<KeyboardAvoidingView
style={[s.container, isDark && s.containerDark]}
// Both platforms use "padding" so the composer/toolbar is pushed up
// above the keyboard via JS-measured keyboard height.
//
// Android previously relied on the native android:windowSoftInputMode
// (adjustResize, see AndroidManifest.xml) with behavior={undefined}
// to let the OS resize the window (see #70/#53). Since adopting
// Expo's mandatory edge-to-edge display, Android no longer resizes
// the window when the keyboard opens — the system assumes insets are
// handled dynamically — so adjustResize became a no-op and the
// bottom toolbar + input were left completely hidden behind the
// keyboard (#147). "padding" restores avoidance without depending
// on native resize.
// "padding" pushes the composer/toolbar above the keyboard. On Android
// the padding is driven by react-native-keyboard-controller's native
// inset tracking (see import note above); on iOS by the keyboard frame
// events. History: this screen previously used the core RN KAV with
// behavior="padding" (#147), which regressed under edge-to-edge — the
// core component's JS-measured keyboard height no longer matched the
// real inset, so the input stayed hidden behind the keyboard.
behavior="padding"
// automaticOffset: measure this view's true on-screen position via
// native viewPositionInWindow instead of the layout-relative frame.
// Under RN core edge-to-edge the content area is already inset by
// status/nav bars, so layout-relative math under-counts the keyboard
// padding by exactly those insets (composer ends up partly behind the
// keyboard — measured 609px of padding for an 820px IME inset).
automaticOffset
keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0}
>
{/* Session info pulldown */}
Expand All @@ -626,6 +661,23 @@ export default function SessionScreen() {
onClose={() => setShowInfo(false)}
/>

{/* Full-screen selectable text (message action sheet -> "Select text") */}
<MessageTextSheet visible={textSheet !== null} text={textSheet ?? ""} onClose={() => setTextSheet(null)} />

{/* Session error (agent run failed) — session.error SSE event sets
this while the user is viewing the session. Dismissible; also
cleared by the next send. */}
{error && (
<View style={[s.banner, s.bannerError]}>
<Text style={[s.bannerText, s.bannerErrorText]} numberOfLines={3}>
{error}
</Text>
<TouchableOpacity onPress={() => useSessions.setState({ error: null })} hitSlop={8}>
<Text style={s.bannerErrorAction}>{t("session.banners.dismiss")}</Text>
</TouchableOpacity>
</View>
)}

{/* SSE reconnect/connected banner */}
{reconnectAttempts > 0 && (
<View style={[s.banner, s.bannerReconnecting]}>
Expand Down Expand Up @@ -675,7 +727,8 @@ export default function SessionScreen() {
message={item.message}
parts={item.parts}
isDark={isDark}
onLongPress={handleMessageLongPress}
onSelectText={openSelectTextSheet}
onEdit={startEditFlow}
/>
)}
contentContainerStyle={s.messageList}
Expand Down Expand Up @@ -1057,6 +1110,17 @@ const s = StyleSheet.create({
bannerConnected: { backgroundColor: "#065f46" },
bannerText: { color: "#ffffff", fontSize: 13, fontWeight: "500" },

// Session error banner (agent run failed)
bannerError: {
backgroundColor: "#991b1b",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
},
bannerErrorAction: { color: "#fecaca", fontSize: 13, fontWeight: "700" },
bannerErrorText: { flex: 1 },

// Pending revert (edit message) banner
bannerRevert: {
backgroundColor: "#1e3a8a",
Expand Down
Loading