diff --git a/android/app/build.gradle b/android/app/build.gradle
index e1dd87a5..a95c58c6 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -91,10 +91,10 @@ android {
namespace 'cc.agentlabs.opencode'
defaultConfig {
- applicationId 'cc.agentlabs.opencode'
+ applicationId 'cc.agentlabs.opencode.test'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 42
+ versionCode 44
versionName "0.4.15"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
diff --git a/android/app/src/main/java/cc/agentlabs/opencode/MainActivity.kt b/android/app/src/main/java/cc/agentlabs/opencode/MainActivity.kt
index 66ff305d..61cfbe0c 100644
--- a/android/app/src/main/java/cc/agentlabs/opencode/MainActivity.kt
+++ b/android/app/src/main/java/cc/agentlabs/opencode/MainActivity.kt
@@ -2,6 +2,9 @@ package cc.agentlabs.opencode
import android.os.Build
import android.os.Bundle
+import android.view.View
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
@@ -17,6 +20,17 @@ class MainActivity : ReactActivity() {
// This is required for expo-splash-screen.
setTheme(R.style.AppTheme);
super.onCreate(null)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+ val root = findViewById(android.R.id.content)
+ val bottom = root.paddingBottom
+ // Android 15+ no longer resizes edge-to-edge windows for the IME.
+ ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
+ view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, bottom + insets.getInsets(WindowInsetsCompat.Type.ime()).bottom)
+ insets
+ }
+ ViewCompat.requestApplyInsets(root)
+ }
}
/**
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
index a8907277..dc8a263c 100644
--- a/android/app/src/main/res/values/colors.xml
+++ b/android/app/src/main/res/values/colors.xml
@@ -2,4 +2,5 @@
#FFFFFF
#023c69
#ffffff
-
\ No newline at end of file
+ #FFFFFF
+
diff --git a/app.json b/app.json
index f3ff1218..a6499fdc 100644
--- a/app.json
+++ b/app.json
@@ -48,6 +48,7 @@
},
"plugins": [
"expo-router",
+ "./plugins/withKeyboardInsets",
"expo-secure-store",
"expo-local-authentication",
[
diff --git a/app/_layout.tsx b/app/_layout.tsx
index c669aac9..fc404ddb 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"
@@ -11,6 +12,7 @@ import { useAuth } from "../src/stores/auth"
import { useConnections } from "../src/stores/connections"
import { useEvents } from "../src/stores/events"
import { useCatalog } from "../src/stores/catalog"
+import { useSessions } from "../src/stores/sessions"
import { useSettings } from "../src/stores/settings"
import { AuthGate } from "../src/components/AuthGate"
import { ErrorBoundary } from "../src/components/ErrorBoundary"
@@ -31,6 +33,9 @@ function RootLayout() {
const { initialize: initAuth, isLoading: authLoading } = useAuth()
const { loadConnections, isLoading: connectionsLoading, client } = useConnections()
const sseStarted = useRef(false)
+ const wasBackgrounded = useRef(false)
+ const sessionRestoreAttempted = useRef(false)
+ const notificationOpened = useRef(false)
const notifPermissionRequested = useRef(false)
// Telemetry consent state: null = loading, 'unknown' = show modal, else decided
@@ -48,6 +53,7 @@ function RootLayout() {
// notifications carry no sessionId (they aren't about a session) — route
// to the home tab instead of "/session/" (an empty, dead-end route).
const unsubNotifications = notifications.onTap((data) => {
+ notificationOpened.current = true
if (data.sessionId) router.push(`/session/${data.sessionId}`)
else router.push("/")
})
@@ -78,6 +84,23 @@ function RootLayout() {
return unsubNotifications
}, [])
+ // Restore only the last selected session reference after Android recreates
+ // the process. The transcript remains server-owned and is fetched by the
+ // session screen; no assistant/tool output is persisted locally.
+ useEffect(() => {
+ if (!client || connectionsLoading || authLoading || consentState === "loading" || sessionRestoreAttempted.current || notificationOpened.current) return
+ sessionRestoreAttempted.current = true
+ void useSessions.getState().loadLastSession().then((last) => {
+ if (!last) return
+ const connections = useConnections.getState()
+ if (last.connectionID && connections.activeConnection?.id !== last.connectionID) return
+ router.replace({
+ pathname: "/session/[id]",
+ params: { id: last.sessionID, ...(last.directory ? { directory: last.directory } : {}) },
+ } as never)
+ })
+ }, [authLoading, client, connectionsLoading, consentState])
+
// Re-arm the biometric app-lock when the app leaves the foreground. Without
// this, "Require Biometric to Open" is bypassable: authenticate() sets
// isAuthenticated=true once at cold start and nothing ever resets it, so the
@@ -85,12 +108,24 @@ function RootLayout() {
// physical access can reopen a backgrounded app straight into session
// history and connection details. lock() flips isAuthenticated back to false
// so AuthGate shows the lock screen (and re-prompts) on next foreground.
- // Fire on "background" only (not the transient "inactive" that the biometric
- // prompt / app switcher / control center produce) to avoid spurious re-locks.
+ // Preserve the in-progress composer draft and mark the SSE transport as no
+ // longer verified when the app really backgrounds. Don't tear the stream down
+ // here: OpenCode continues server-side regardless, and Android may keep the
+ // socket alive. On the next foreground, reconnectNow replaces a stale reader
+ // immediately rather than waiting on a JS timer that Doze may have frozen.
+ // Biometric locking remains restricted to the actual background transition.
useEffect(() => {
const sub = AppState.addEventListener("change", (next) => {
- if (next === "background" && useAuth.getState().settings.requireBiometric) {
- useAuth.getState().lock()
+ if (next === "background") {
+ wasBackgrounded.current = true
+ void useSessions.getState().flushDrafts().catch((err) => console.warn("[Sessions] Draft flush failed:", err))
+ useEvents.getState().background()
+ if (useAuth.getState().settings.requireBiometric) useAuth.getState().lock()
+ return
+ }
+ if (next === "active" && wasBackgrounded.current) {
+ wasBackgrounded.current = false
+ if (sseStarted.current) useEvents.getState().reconnectNow()
}
})
return () => sub.remove()
@@ -169,6 +204,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 +265,7 @@ function RootLayout() {
}}
/>
+
)
}
diff --git a/app/connection/[id].tsx b/app/connection/[id].tsx
index 49701d21..b76a74d5 100644
--- a/app/connection/[id].tsx
+++ b/app/connection/[id].tsx
@@ -5,6 +5,8 @@ import {
TextInput,
TouchableOpacity,
ScrollView,
+ KeyboardAvoidingView,
+ Platform,
StyleSheet,
useColorScheme,
ActivityIndicator,
@@ -181,8 +183,13 @@ export default function EditConnectionScreen() {
}
return (
-
+
@@ -316,6 +323,7 @@ export default function EditConnectionScreen() {
+
)
}
diff --git a/app/connection/add.tsx b/app/connection/add.tsx
index b8616086..40ac5cd2 100644
--- a/app/connection/add.tsx
+++ b/app/connection/add.tsx
@@ -5,6 +5,8 @@ import {
TextInput,
TouchableOpacity,
ScrollView,
+ KeyboardAvoidingView,
+ Platform,
StyleSheet,
useColorScheme,
ActivityIndicator,
@@ -133,6 +135,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 +215,8 @@ export default function AddConnectionScreen() {
url: url.trim(),
directory: directory.trim() || undefined,
username: username.trim() || undefined,
+ api: result.api,
+ serverVersion: result.serverVersion,
},
password || undefined,
)
@@ -315,8 +323,13 @@ export default function AddConnectionScreen() {
// Quick connect mode - simplified
if (mode === "quick") {
return (
-
+
@@ -516,13 +529,19 @@ export default function AddConnectionScreen() {
+
)
}
// Advanced mode - full options
return (
-
+
@@ -658,6 +677,7 @@ export default function AddConnectionScreen() {
)}
+
)
}
diff --git a/app/session/[id].tsx b/app/session/[id].tsx
index 97f885f0..9e2f5ca5 100644
--- a/app/session/[id].tsx
+++ b/app/session/[id].tsx
@@ -7,7 +7,8 @@ import {
TouchableOpacity,
StyleSheet,
useColorScheme,
- KeyboardAvoidingView,
+ useWindowDimensions,
+ Keyboard,
Platform,
ActivityIndicator,
Alert,
@@ -20,6 +21,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 +37,7 @@ import {
VariantPicker,
ImageAttachments,
SessionInfo,
+ MessageTextSheet,
type SlashCommand,
type Attachment,
} from "../../src/components/chat"
@@ -39,6 +47,8 @@ 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"
+import { composerMaxHeight, keyboardVerticalOffset } from "../../src/lib/session-layout"
// --- Builtin slash commands ---
const BUILTIN_COMMANDS: SlashCommand[] = [
@@ -77,14 +87,32 @@ 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)
const modelSheetRef = useRef(null)
const variantSheetRef = useRef(null)
const [input, setInput] = useState("")
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const submittingRef = useRef(false)
+ const [keyboardY, setKeyboardY] = useState(null)
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)
+
+ useEffect(() => {
+ if (Platform.OS !== "android") return
+ const show = Keyboard.addListener("keyboardDidShow", (event) => setKeyboardY(event.endCoordinates.screenY))
+ const hide = Keyboard.addListener("keyboardDidHide", () => setKeyboardY(null))
+ setKeyboardY(Keyboard.metrics()?.screenY ?? null)
+ return () => {
+ show.remove()
+ hide.remove()
+ }
+ }, [])
+ const inputMaxHeight = composerMaxHeight(Platform.OS, windowHeight, keyboardY, insets.top, fontScale)
const {
currentSession,
@@ -93,7 +121,11 @@ export default function SessionScreen() {
isLoading,
loadingMore,
hasMore,
+ error,
selectSession,
+ loadDraft,
+ setDraft,
+ clearDraft,
sendMessage,
abortSession,
loadOlderMessages,
@@ -101,16 +133,36 @@ export default function SessionScreen() {
unrevertSession,
} = useSessions()
+ // The native stack keeps inactive session screens mounted while another
+ // route is selected. Until this route's focus load finishes, the single
+ // session store can still contain the previous route's data.
+ const currentSessionMatchesRoute = currentSession?.id === id
+ const selectedSession = currentSessionMatchesRoute ? currentSession : null
+
+ const inputRef = useRef(input)
+ inputRef.current = input
+ const inputRevision = useRef(0)
+ const setComposerInput = useCallback((update: string | ((previous: string) => string)) => {
+ const next = typeof update === "function" ? update(inputRef.current) : update
+ inputRevision.current += 1
+ inputRef.current = next
+ setInput(next)
+ if (id) {
+ if (next.length > 0) setDraft(id, next)
+ else clearDraft(id)
+ }
+ }, [clearDraft, id, setDraft])
+
// Derive sending state for this specific session
- const isSending = useSessions((s) => !!(currentSession && s.sending[currentSession.id]))
+ const isSending = useSessions((s) => !!(id && s.sending[id]))
const { authenticateForMessage } = useAuth()
const { client, clientForDirectory } = useConnections()
// Use directory-aware client for sessions that belong to a project other than the active one
const sessionClient = useMemo(
- () => (currentSession?.directory ? (clientForDirectory(currentSession.directory) ?? client) : client),
- [currentSession?.directory, clientForDirectory, client],
+ () => (selectedSession?.directory ? (clientForDirectory(selectedSession.directory) ?? client) : client),
+ [selectedSession?.directory, clientForDirectory, client],
)
// Catalog
@@ -126,11 +178,11 @@ export default function SessionScreen() {
const cycleAgent = catalog.cycleAgent
// Permission & question state
- const sessionID = currentSession?.id
+ const sessionID = id
const permissions = useEvents((s) => (sessionID ? s.permissions[sessionID] : undefined)) || []
const questions = useEvents((s) => (sessionID ? s.questions[sessionID] : undefined)) || []
- const shortDir = getShortDir(currentSession?.directory)
+ const shortDir = getShortDir(selectedSession?.directory)
const [showScrollButton, setShowScrollButton] = useState(false)
// SSE reconnect banner
@@ -141,8 +193,8 @@ export default function SessionScreen() {
// Voice input — transcript appends to the text input on completion
const speech = useSpeech(
useCallback((text: string) => {
- setInput((prev) => (prev ? prev + " " + text : text))
- }, []),
+ setComposerInput((prev) => (prev ? prev + " " + text : text))
+ }, [setComposerInput]),
)
// Surface speech recognition failures (e.g. mic permission denied). Keyed
@@ -175,28 +227,26 @@ export default function SessionScreen() {
// "temp-" IDs (assigned client-side before the server responds, see
// sendMessage) aren't part of that sort order — always keep them so a
// message sent concurrently with a revert isn't hidden.
- const revertMessageID = currentSession?.revert?.messageID
+ const revertMessageID = selectedSession?.revert?.messageID
// Inverted FlatList: data is reversed (newest first) so newest renders at bottom
const messageData = useMemo(
() =>
- (messages || [])
+ currentSessionMatchesRoute
+ ? (messages || [])
.filter((msg) => !revertMessageID || msg.id.startsWith("temp-") || msg.id < revertMessageID)
.map((msg) => ({
message: msg,
parts: (parts && parts[msg.id]) || [],
}))
- .reverse(),
- [messages, parts, revertMessageID],
+ .reverse()
+ : [],
+ [currentSessionMatchesRoute, 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.
- const inputRef = useRef(input)
- inputRef.current = input
-
+ // 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 applyRevertResult = useCallback((result: Awaited>) => {
if (!result.ok) {
if (result.reason === "unsupported") {
@@ -208,7 +258,7 @@ export default function SessionScreen() {
}
return
}
- setInput(result.text)
+ setComposerInput(result.text)
// Restore attachments in the same shape the composer's own picker
// functions (pickFromLibrary/pickFromCamera/pasteFromClipboard) use.
setAttachments(
@@ -216,40 +266,64 @@ export default function SessionScreen() {
.filter((f): f is typeof f & { url: string; mime: string } => !!f.url && !!f.mime)
.map((f) => ({ uri: f.url, mime: f.mime, filename: f.filename })),
)
- }, [t])
+ }, [setComposerInput, 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],
+ )
- // 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])
+ // 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 })
@@ -265,6 +339,20 @@ export default function SessionScreen() {
useFocusEffect(
useCallback(() => {
if (!id) return
+ // Load the session-specific encrypted draft on focus. The revision guard
+ // prevents a slower SecureStore read from overwriting text typed while it
+ // was in flight, and `focused` prevents a previous route's read landing
+ // after the user switches sessions.
+ let focused = true
+ const revision = ++inputRevision.current
+ inputRef.current = ""
+ setInput("")
+ void loadDraft(id).then((draft) => {
+ if (!focused || inputRevision.current !== revision) return
+ inputRef.current = draft
+ setInput(draft)
+ }).catch((err) => console.warn("[Session] Failed to restore composer draft:", err))
+
selectSession(id, directory).then(() => {
// Re-fetch pending permissions/questions from the server to recover from
// missed SSE events or failed optimistic removals
@@ -272,11 +360,13 @@ export default function SessionScreen() {
const c = directory ? (connState.clientForDirectory(directory) ?? connState.client) : connState.client
if (c) refreshPending(c, id)
})
- }, [id, directory]),
+ return () => { focused = false }
+ }, [id, directory, loadDraft, selectSession]),
)
// Sync model chip from latest assistant message
useEffect(() => {
+ if (!currentSessionMatchesRoute) return
if (!messages || messages.length === 0) return
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
@@ -289,7 +379,7 @@ export default function SessionScreen() {
return
}
}
- }, [currentSession?.id, messages?.length])
+ }, [currentSessionMatchesRoute, currentSession?.id, messages?.length])
// Slash command handler
const handleSlashSelect = useCallback(
@@ -300,18 +390,18 @@ export default function SessionScreen() {
router.back()
return
case "model":
- setInput("")
+ setComposerInput("")
modelSheetRef.current?.expand()
return
case "agent":
- setInput("")
+ setComposerInput("")
cycleAgent()
return
}
}
- setInput(`/${cmd.trigger} `)
+ setComposerInput(`/${cmd.trigger} `)
},
- [router, cycleAgent],
+ [router, cycleAgent, setComposerInput],
)
// --- Image picking ---
@@ -394,12 +484,12 @@ export default function SessionScreen() {
if (hasText) {
const text = await Clipboard.getStringAsync()
if (text) {
- setInput((prev) => prev + text)
+ setComposerInput((prev) => prev + text)
return
}
}
Alert.alert(t("session.alerts.emptyClipboardTitle"), t("session.alerts.emptyClipboardMessage"))
- }, [t])
+ }, [setComposerInput, t])
const removeAttachment = useCallback((index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index))
@@ -407,46 +497,62 @@ export default function SessionScreen() {
// --- Send ---
const handleSend = async () => {
- if (!input.trim() && attachments.length === 0) return
- const authenticated = await authenticateForMessage()
- if (!authenticated) {
- Alert.alert(t("session.alerts.authRequiredTitle"), t("session.alerts.authRequiredMessage"))
- return
+ if (submittingRef.current) return
+ const text = inputRef.current
+ const files = [...attachments]
+ if (!text.trim() && files.length === 0) return
+ submittingRef.current = true
+ setIsSubmitting(true)
+
+ const sessionID = selectedSession?.id
+ const clearSubmittedDraft = () => {
+ // Text entry is disabled while the request is pending. Still compare the
+ // value so a programmatic edit (e.g. a route/revert action) is never lost.
+ if (inputRef.current === text) {
+ inputRef.current = ""
+ setInput("")
+ if (sessionID) clearDraft(sessionID)
+ }
+ setAttachments((current) => current.filter((file) => !files.includes(file)))
}
- const text = input.trim()
- const files = [...attachments]
- setInput("")
- setAttachments([])
-
- // Server slash commands (no attachments for commands)
- if (text.startsWith("/") && files.length === 0) {
- const [cmdName, ...args] = text.split(" ")
- const name = cmdName.slice(1)
- const match = serverCommands.find((c) => c.name === name)
- if (match && sessionClient && currentSession) {
- sessionClient.session
- .command(currentSession.id, {
+ try {
+ const authenticated = await authenticateForMessage()
+ if (!authenticated) {
+ Alert.alert(t("session.alerts.authRequiredTitle"), t("session.alerts.authRequiredMessage"))
+ return
+ }
+ if (!currentSessionMatchesRoute || !selectedSession) return
+
+ // Server slash commands are parsed as commands; regular prompts are sent
+ // byte-for-byte as composed, including leading/trailing whitespace.
+ if (text.startsWith("/") && files.length === 0) {
+ const [cmdName, ...args] = text.trim().split(" ")
+ const name = cmdName.slice(1)
+ const match = serverCommands.find((c) => c.name === name)
+ if (match && sessionClient) {
+ await sessionClient.session.command(selectedSession.id, {
command: name,
arguments: args.join(" "),
agent,
model: model ? `${model.providerID}/${model.modelID}` : undefined,
})
- .catch((err) => console.error("Command failed:", err))
- return
+ clearSubmittedDraft()
+ return
+ }
}
- }
- // Messages are queued server-side when the session is busy.
- // No need to abort - just send and it will be processed after current response.
- try {
+ // Messages are queued server-side when the session is busy. Keep the
+ // encrypted draft and attachments until the submission POST is accepted.
await sendMessage(text, model || undefined, agent || undefined, files, variant || undefined)
+ clearSubmittedDraft()
} catch (err) {
console.error("Send failed:", err)
- // Restore the user's text and attachments so their input isn't lost.
- setInput((prev) => (prev ? prev : text))
- setAttachments((prev) => (prev.length ? prev : files))
+ // Nothing was cleared, so the complete draft remains available to retry.
Alert.alert(t("session.alerts.sendFailedTitle"), t("session.alerts.sendFailedMessage"))
+ } finally {
+ submittingRef.current = false
+ setIsSubmitting(false)
}
}
@@ -570,7 +676,7 @@ export default function SessionScreen() {
<>
(
{shortDir && (
@@ -593,24 +699,26 @@ export default function SessionScreen() {
{/* Session info pulldown */}
setShowInfo(false)}
/>
+ {/* Full-screen selectable text (message action sheet -> "Select text") */}
+ 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 && (
+
+
+ {error}
+
+ useSessions.setState({ error: null })} hitSlop={8}>
+ {t("session.banners.dismiss")}
+
+
+ )}
+
{/* SSE reconnect/connected banner */}
{reconnectAttempts > 0 && (
@@ -649,7 +774,7 @@ export default function SessionScreen() {
// The composer was prefilled with the reverted message's text/
// attachments (see applyRevertResult) — clear it so Undo doesn't
// leave a stale draft that could be sent as a duplicate.
- setInput("")
+ setComposerInput("")
setAttachments([])
}}
hitSlop={8}
@@ -675,7 +800,8 @@ export default function SessionScreen() {
message={item.message}
parts={item.parts}
isDark={isDark}
- onLongPress={handleMessageLongPress}
+ onSelectText={openSelectTextSheet}
+ onEdit={startEditFlow}
/>
)}
contentContainerStyle={s.messageList}
@@ -712,7 +838,7 @@ export default function SessionScreen() {
)}
{/* Status */}
- {currentSession && }
+ {selectedSession && }
{/* Permissions */}
{permissions.map((perm) => (
@@ -796,7 +922,7 @@ export default function SessionScreen() {
{/* Stop button: only when busy and no input */}
@@ -832,7 +958,7 @@ export default function SessionScreen() {
)}
{/* Send button: when there's input */}
{!speech.listening && (input.trim() || attachments.length > 0) && (
-
+
)}
@@ -1057,6 +1183,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",
diff --git a/docs/compatibility-and-lifecycle.md b/docs/compatibility-and-lifecycle.md
new file mode 100644
index 00000000..c87462de
--- /dev/null
+++ b/docs/compatibility-and-lifecycle.md
@@ -0,0 +1,127 @@
+# Compatibility and lifecycle
+
+## Supported OpenCode versions
+
+| Server line | Status | Notes |
+| --- | --- | --- |
+| opencode 1.18.x (v1) | **Supported** | The original transport in `src/lib/sdk-v1.ts`. Still the common case via `npm install -g opencode-ai`. |
+| opencode 1.19.x (v1) | **Supported** | Same v1 transport; no breaking API changes observed. |
+| opencode 2.x (v2) | **Supported (unit-tested)** | The v2 transport in `src/lib/sdk.ts` with `src/lib/v2-shape.ts` normalization. Live server interoperability not yet verified. |
+
+## Version/capability detection
+
+Detection is by **content type, never status code** — an unknown route on either
+server falls through to the web SPA and answers `200 text/html`:
+
+| probe | v2 server | v1 server |
+| --- | --- | --- |
+| `GET /api/info` | `401` unauthenticated / `200 application/json` | `200 text/html` (SPA) |
+| `GET /global/health` | `200 text/html` (SPA) | `200 application/json` |
+
+`src/lib/api-detect.ts` implements this probing. Unprobed saved connections
+default to v1 behavior.
+
+## Permission protocols
+
+### v1 (legacy)
+
+- Event: `permission.asked` with flat `Permission.Request` properties.
+- Reply: `POST /session/{id}/permission/{requestID}/reply` with `{reply: "once"|"always"|"reject"}`.
+
+### v2 (current)
+
+- Event: `permission.v2.asked` with `source.callID` and nested `PermissionV2` shape.
+- Reply: `POST /api/session/{id}/permission/{requestID}/reply` with `{reply: "once"|"always"|"reject"}`.
+- Some v2 builds also emit `permission.asked` / `permission.replied`; the translator accepts both shapes.
+
+See [`v2-api-map.md`](v2-api-map.md) for the full protocol map.
+
+## SSE lifecycle
+
+### Connection states
+
+The `TransportState` type in `src/lib/sse-lifecycle.ts` tracks:
+
+- `idle` — no connection attempt in progress.
+- `connecting` — initial connection attempt.
+- `live` — SSE stream is open and receiving data.
+- `reconnecting` — connection lost, backoff/retry in progress.
+- `backgrounded` — app is backgrounded; stream may be frozen by Android.
+- `auth-required` — server rejected credentials; no retry.
+
+### Reconnection policy
+
+- Exponential backoff with jitter: 1s, 2s, 4s, 8s, 15s (capped at 15s).
+- Retry counter resets only after actual stream data is received (not just a successful connect).
+- Foreground reconnect is skipped if a stream is already live or a reconnect attempt is in flight.
+- Auth errors (401/403) stop the retry loop entirely.
+
+### Background/foreground recovery
+
+1. **Background**: `app/_layout.tsx` calls `useEvents.getState().background()` which sets transport to `backgrounded` and flushes drafts to SecureStore.
+2. **Foreground**: `reconnectNow()` checks if a reconnect is needed (not already live, not auth-error), aborts any stale reader, and calls `connect()`.
+3. **After reconnect**: `resyncBusySessions()` and `refreshAllPending()` reconcile state. `reconcileOpenSession()` fetches the current session's messages and authoritative status.
+
+### Idle connection detection
+
+A half-open SSE read times out (configurable, default 60s) and cancels its reader. This prevents the app from showing a "connected" state when the server has stopped sending data.
+
+## Session recovery
+
+### Process recreation
+
+- The last-selected session ID is persisted in SecureStore.
+- On app launch, `app/_layout.tsx` restores the session reference and triggers a background refresh.
+- Drafts are restored from encrypted per-session storage.
+
+### Session switching
+
+- Switching to a different session triggers a cold load (spinner shown).
+- Re-selecting the current session triggers a background refresh (no spinner).
+
+### Message reconciliation
+
+- `refreshMessages()` fetches the latest messages from the server.
+- `sessionActivityFromMessages()` derives activity from the last message type.
+- `reconcileOpenSession()` combines server status with local message analysis to determine the true session state.
+
+## Pending permission and question reconciliation
+
+- On reconnect, `refreshAllPending()` fetches all pending permissions and questions from the server.
+- New items trigger notifications with generic lock-screen text.
+- Existing items are deduplicated by ID.
+- The pending registry is updated atomically.
+
+## Draft persistence
+
+- Drafts are stored per-session in SecureStore using encrypted keys.
+- Keys follow the pattern `opencode_draft_{sessionID}`.
+- Drafts are flushed on background and on submission.
+- Empty text removes only that session's draft.
+- Long Unicode, Markdown, and line-ending content is preserved exactly.
+
+## Long-prompt behavior
+
+- No arbitrary composer character limit (the old `maxLength={10000}` has been removed).
+- Prompts up to 100,000 characters are preserved exactly.
+- Submission uses `buildPromptRequest()` which serializes text without trimming.
+- Failed or unaccepted submissions leave the prompt and attachments available for retry.
+- Duplicate submissions are prevented via the `sending` state flag.
+
+## Android background execution limitations
+
+- Android does not guarantee permanent background execution. The app does not request `FOREGROUND_SERVICE` or `WAKE_LOCK`.
+- When backgrounded, the SSE stream may be frozen or killed by the OS.
+- Drafts are flushed to SecureStore on background to prevent data loss.
+- On foreground, the app reconnects and reconciles state.
+- The UI accurately represents `reconnecting`, `synchronizing`, `connected`, and `failed` states.
+- A successful connection is not shown until the necessary state synchronization has completed.
+
+## Known limitations requiring live-device testing
+
+- **Clipboard integrity**: Unit tests verify prompt serialization, but actual Android clipboard round-trip (paste from external app) requires device testing.
+- **Keyboard behavior**: Layout helpers are unit-tested, but actual IME interaction varies by device and keyboard app.
+- **SSE lifecycle**: Reconnection logic is unit-tested with mocked streams, but real network interruption and server restart behavior requires device testing.
+- **Process recreation**: Session restoration is implemented but requires device testing to verify across actual process death scenarios.
+- **OpenCode v2 live compatibility**: Protocol shapes are unit-tested against captured v2 events, but a live v2 server has not been tested.
+- **Long-prompt server acceptance**: Client-side tests verify prompt integrity, but server-side acceptance of very long prompts depends on the server's configuration.
diff --git a/docs/security.md b/docs/security.md
index 0a6cdaab..43ea5e98 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -125,7 +125,7 @@ Use `gh api /repos///git/refs/tags/v1.1.5` to get SHAs.
---
-#### M-03: `permission.asked` notification can leak tool file-path patterns
+#### M-03: `permission.asked` notification can leak tool file-path patterns — addressed
**File:** `src/stores/events.ts:294`
**Description:**
@@ -133,8 +133,8 @@ Use `gh api /repos///git/refs/tags/v1.1.5` to get SHAs.
body: req.patterns?.join(", ") || "A tool needs your approval",
```
When the opencode AI agent requests a file-access permission, the notification body shows the glob patterns (e.g., `/home/user/secrets/*.env`). These patterns can appear on the device lock screen before the user authenticates, visible to someone with physical device access.
-**Remediation:** Replace the notification body with a generic string ("A tool is requesting file access — open app to review") and only reveal patterns inside the locked/authenticated app.
-**Status:** Open
+**Remediation:** Permission and question notifications now use fixed generic title/body text. Tool resources and question content are shown only in the in-app prompt.
+**Status:** Addressed in the Android stability follow-up.
---
@@ -221,7 +221,7 @@ When the opencode AI agent requests a file-access permission, the notification b
4. **[M-02] Pin critical third-party GitHub Actions to commit SHAs**, especially `r0adkll/upload-google-play` which runs with the Play Store service account JSON in scope.
-5. **[M-03] Sanitize `permission.asked` notification body.** Replace file-path patterns in lock-screen notifications with a generic message.
+5. ~~**[M-03] Sanitize `permission.asked` notification body.**~~ Addressed: permission and question notifications now use generic text.
---
diff --git a/docs/threat-model.md b/docs/threat-model.md
index f14eb0ee..42f6086e 100644
--- a/docs/threat-model.md
+++ b/docs/threat-model.md
@@ -11,12 +11,12 @@ Version: 0.2.3
|---|---|---|
| opencode server URL | Medium — reveals network topology | `expo-secure-store` (`opencode_connections`) |
| HTTP Basic auth password for opencode server | High — grants full server access | `expo-secure-store` (`opencode_password_`) |
-| User prompts sent to the AI model | High — may contain proprietary code or PII | In-flight only (not persisted on device) |
+| User prompts sent to the AI model | High — may contain proprietary code or PII | Sent prompts remain server-side; the unsent composer draft is encrypted in `expo-secure-store` per session and deleted after accepted submission |
| AI responses / generated code | High — proprietary output | In-flight only (not persisted on device) |
| Sentry DSN | Low — public by design; quota-abuse risk | Bundle (EXPO_PUBLIC_) + SecureStore (consent state) |
| Biometric/PIN auth settings | Low | `expo-secure-store` (`opencode_auth_settings`) |
| Telemetry consent decision | Low | `expo-secure-store` (`opencode_telemetry_consent`) |
-| Session IDs | Low — server-assigned, ephemeral | In-memory only |
+| Session IDs | Low — server-assigned | Current last-selected session reference in `expo-secure-store`; no transcript is stored |
| Notification preferences | Low | `expo-secure-store` (`opencode_settings`) |
---
@@ -70,7 +70,7 @@ Version: 0.2.3
- All sensitive data in `expo-secure-store` (iOS Keychain with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`; Android Keystore-backed EncryptedSharedPreferences). Data is inaccessible when the device is locked.
- Optional biometric app lock (`requireBiometric` setting). Default off for UX; users with sensitive servers should enable it.
- Notification lock-screen preview: session titles may appear in "Task completed" notifications. No prompt content or AI response content is included in notification bodies.
-- **Known gap (M-03):** `permission.asked` notifications can show tool file-path glob patterns on lock screen. Fix pending.
+- Permission/question notifications use generic lock-screen text; tool resources and form content remain in the authenticated in-app prompt (M-03 addressed).
**Residual risk:** Medium. Without biometric lock enabled, a device that auto-unlocks (e.g., trusted location) exposes app contents. Users should be prompted to enable biometric lock during onboarding when a server is added.
diff --git a/docs/upstream-integration-ledger.md b/docs/upstream-integration-ledger.md
new file mode 100644
index 00000000..594c6a82
--- /dev/null
+++ b/docs/upstream-integration-ledger.md
@@ -0,0 +1,46 @@
+# Upstream integration ledger
+
+This branch was compared against all 19 open upstream pull requests in
+`dzianisv/opencode-mobile` as of 2026-10-17. The purpose was to carry over
+fixes addressing Android reliability, prompt retention, and OpenCode API
+compatibility without replacing the fork's PR #216 base.
+
+## Integrated
+
+| PR | Purpose | Status | Implementation |
+| --- | --- | --- | --- |
+| #209 | OpenCode v2 server support with v1 compatibility | **Integrated** | Commit `4bf0380` plus working-tree extensions. The app detects v1/v2 transports via content-type probing and maps v2 session, message, event, form, provider, and permission shapes onto existing interfaces. PermissionV2 uses current event names (`permission.v2.asked`/`permission.v2.replied`), `source.callID`, and `{reply}` wire body. See [`v2-api-map.md`](v2-api-map.md). |
+| #211 | Distinguish 401 with no credentials from rejected credentials | **Integrated** | Commit `83da1ac`. Diagnostics differentiate an authentication challenge when no credentials were sent from a rejection of supplied credentials. |
+| #191 | Preserve per-session message drafts | **Adapted** | `src/lib/session-drafts.ts` and `src/stores/sessions.ts` implement encrypted per-session draft storage using SecureStore. Drafts flush on background and restore after process recreation. |
+
+## Adapted (relevant pieces extracted)
+
+| PR | Purpose | Status | Decision |
+| --- | --- | --- | --- |
+| #182 | Keyboard/composer, auto-scroll, dead SSE, stale transcripts, etc. | **Adapted** | Reviewed for lifecycle, SSE recovery, draft, and message-copy concerns. Adapted the lifecycle and draft pieces to the existing stores rather than transplanting the wider patch, which overlaps this fork's session and transport behavior. |
+| #193 | Prevent keyboard overlap across input screens | **Adapted** | Kept the existing IME/window-inset approach and made targeted composer layout changes (`src/lib/session-layout.ts`) rather than taking conflicting manifest/windowSoftInputMode changes. |
+| #208 | Keep IME prompts usable | **Adapted** | Reviewed; the existing `adjustResize` + `react-native-keyboard-controller` approach already addresses the core issue. No additional changes needed. |
+| #206 | Restore adjustResize for edge-to-edge displays | **Adapted** | Reviewed; the fork's `MainActivity.kt` already applies IME insets on Android 15+ via `ViewCompat.setOnApplyWindowInsetsListener`. No transplant needed. |
+| #201 | Keep composer synchronized with IME | **Adapted** | Reviewed; `src/lib/session-layout.ts` provides keyboard-aware composer sizing that keeps the input visible. |
+| #203 | Keyboard cover input + blank card | **Adapted** | Reviewed; the existing keyboard-controller integration and composer layout helpers address the input-covering issue. The blank-card rendering fix is covered by existing message-list logic. |
+
+## Reviewed, not integrated
+
+| PR | Purpose | Status | Decision |
+| --- | --- | --- | --- |
+| #217 | Never lose Play review feedback to a missing label | **Rejected** | Play Store tooling concern; unrelated to app stability or API compatibility. |
+| #215 | Navigate previous and next message starts | **Rejected** | Chat navigation feature; orthogonal to stability/API work. |
+| #200 | Title swarm delegation rows | **Rejected** | Chat UI label fix; unrelated to this branch's scope. |
+| #199 | Link repository and publisher on F-Droid and in Settings | **Rejected** | Metadata/branding change; not a stability fix. |
+| #196 | Label delegated task tool calls | **Rejected** | Chat UI label fix; unrelated. |
+| #195 | Post the AGE-100 after-number | **Rejected** | Documentation only; no code change. |
+| #192 | Fullscreen diffs and task change review | **Rejected** | Feature addition; unrelated to stability. |
+| #185 | Add Italian locale | **Rejected** | i18n contribution; unrelated to this branch. |
+| #161 | Notification lock-screen leaks, settings persistence + bugs | **Deferred** | Notification privacy is already addressed by the generic notification text in `src/lib/notify-format.ts`. Settings persistence bugs are unrelated to this branch's scope. |
+
+## Verification status
+
+- `npm run typecheck` passes.
+- The Node 22 test run passes all 395 tests.
+- `./gradlew assembleRelease` succeeds with Node 22 and Sentry auto-upload disabled. The resulting APK reports application ID `cc.agentlabs.opencode.test`, keeping it installable alongside the production app.
+- No Android device was attached during this run, so the APK has not been installed or lifecycle-tested on-device. Live OpenCode v2 server interoperability also remains to be exercised against a running v2 server.
diff --git a/docs/v2-api-map.md b/docs/v2-api-map.md
new file mode 100644
index 00000000..4241ecfc
--- /dev/null
+++ b/docs/v2-api-map.md
@@ -0,0 +1,122 @@
+# opencode v2 HTTP API map (verified against v2.0.16, /openapi.json + live probe)
+
+Authoritative source: `GET /openapi.json` on any v2 server (OpenAPI 3.1, ~113 paths).
+Full spec saved at `/tmp/opencode/openapi.json`; extracted schemas in
+`/tmp/opencode/schemas.txt`, `api2.txt`, `api3.txt`, `api4.txt`; live event
+capture in `/tmp/opencode/events_full.txt`.
+
+## Breaking changes vs v1 (what the app used)
+
+1. **All API routes moved under `/api/`**. Old paths (`/session`, `/global/event`,
+ `/global/health`, `/file`, `/provider`, ...) now fall through to the SPA web UI
+ (HTML 200) — so the app silently renders garbage instead of erroring.
+2. **Envelope responses**: list endpoints return `{data: [...], cursor?}` or
+ `{location, data}` instead of bare arrays.
+3. **Health**: `/global/health` gone → `GET /api/info` → `{version, pid, urls, paths}`.
+4. **Auth**: same Basic auth (`OPENCODE_SERVER_PASSWORD`), username defaults `opencode`
+ (check headers.ts — v1 used `OPENCODE`? verify) → 401 on wrong creds.
+5. **Events SSE** `/api/event` (was `/global/event`): events are
+ `{id: "evt_...", type: "...", data: {...}}` (was `{type, properties}`).
+6. **Messages**: no more `MessageWithParts` (`info` + `parts[]`). Typed messages:
+ - `user`: `{id, time, type:"user", text, files?, agents?, skills?}`
+ - `assistant`: `{id, time:{created,streamed?,completed?}, type:"assistant", agent, model:{id,providerID,variant?}, content:[Text|Reasoning|Tool], snapshot?, finish?, cost, tokens, error?, retry?}`
+ - content Text: `{type:"text", text}`
+ - content Reasoning: `{type:"reasoning", text, time?}`
+ - content Tool: `{type:"tool", id, name, executed?, state: ToolState, time}`
+ - ToolState: `streaming {input:string}` | `running {input,metadata}` | `completed {input, content:[Tool.Content], metadata}` | `error {input, error, content?}`
+ - Tool.Content: `{type:"text", text}` or `{type:"file", ...}` (Tool.TextContent / Tool.FileContent)
+ - others: `idle {outcome}`, `compaction`, `system`, `synthetic`, `shell`, `skill`,
+ `agent-switched`, `model-switched`, `location-switched`
+7. **Prompt**: `POST /api/session/{id}/prompt` body `{text, files?:[{uri,name}], agents?, skills?, delivery?: "steer"|"queue", resume?}`.
+ Model/agent are session state, set via `POST /api/session/{id}/model {model:{id,providerID,variant?}}` and `POST /api/session/{id}/agent {agent}` — NOT per-prompt.
+8. **Command**: `POST /api/session/{id}/command {name, text}`.
+9. **Abort** → `POST /api/session/{id}/interrupt`.
+10. **Session list**: `GET /api/session?limit&order&search&directory&project&cursor` → `{data: Session.Info[], cursor:{previous,next}}`. No more `/experimental/session`.
+11. **Session.Info**: `{id, projectID, agent?, model?, cost, tokens, outcome?, time:{created,updated,idle?,viewed?,archived?}, title?, subpath?, metadata?, permissions?, revert?, location:{directory}}`. No `slug`, no `version`, no `share`, no `summary`.
+12. **Diff**: `GET /api/session/{id}/diff` → `{data: FileDiff.Info[]}` where
+ FileDiff.Info = `{file, patch, additions, deletions, status:"added"|"deleted"|"modified"}`.
+13. **Revert**: stage `POST .../revert/stage {messageID, files?}`; commit `POST .../revert/commit`; unstage `DELETE .../revert`. Session.Info.revert = `{messageID, partID?, snapshot?, files?}`.
+14. **Permissions**: list `GET /api/session/{id}/permission` → `{data:[Permission.Request]}`;
+ Permission.Request = `{id:"per...", sessionID, action, resources:[string], save?, metadata?, source?, message?}`;
+ reply `POST /api/session/{id}/permission/{requestID}/reply {reply:"once"|"always"|"reject", message?}`. The current schema (`Permission.ReplyBody`) calls this field `reply`.
+ Global list: `GET /api/permission/request`.
+15. **Questions → Forms**: `GET /api/session/{id}/form` → `{data:[Form.Info]}`;
+ Form.Info = `{id:"frm_...", sessionID, title, fields}` (typed fields: string/number/integer/boolean/multiselect/external);
+ reply `POST .../form/{formID}/reply {answer}`; reject = `DELETE .../form/{formID}`.
+ The `question` tool is a form on the server (`title:"Questions"`, field keys `q0..qN`,
+ `metadata.kind:"question"`). Its SSE event **nests the form**: `form.created`
+ carries `{form: Form.Info}`, while `permission.asked` carries the flat
+ Permission.Request — reading `form.created` off `data` directly drops it
+ (live capture `evt_0d4be7612002H8CTkutJnzghGX` on v2.0.16).
+16. **Agents**: `GET /api/agent` → `{location, data:[Agent.Info]}`;
+ Agent.Info = `{id, name, model?, request, system?, description?, mode, hidden, color?, steps?, permissions}`.
+17. **Commands list**: `GET /api/command` → `{location, data:[Command.Info]}`.
+18. **Providers/models**: `GET /api/provider` → `{location, data:[Provider.Info]}`;
+ models: `GET /api/model` → `{location, data:[Model.Info]}`; Model.Info =
+ `{id, modelID, providerID, name, capabilities, variants[], time, cost[], status, enabled, limit:{context,output}}`.
+ Default: `GET /api/model/default`.
+19. **Filesystem**: `GET /api/fs/list?path=` → `{location, data:[{path,type:"file"|"directory"}]}`;
+ `GET /api/fs/find`, `GET /api/fs/read/*`, `POST /api/experimental/fs/write`. No `/file/roots`.
+20. **Location** (was `/path` + directory header): `GET /api/location` → `{directory, project:{id,directory,canonical}}`.
+ Project list `GET /api/project` → array of `{id, canonical, time, sandboxes}`.
+21. **Config**: `GET /api/config` → array of config documents.
+22. **Active sessions**: `GET /api/session/active` → `{data: {ses_xxx: {type:"running"}}}`.
+
+## Event types (live-verified + from binary strings)
+
+Core execution: `session.execution.started|failed|interrupted|succeeded`
+Steps: `session.step.started {sessionID, agent, model, assistantMessageID, started}`,
+`session.step.streamed`, `session.step.ended {finish, rawFinish, cost, tokens}`, `session.step.failed`
+Text: `session.text.started|delta|ended {assistantMessageID, ordinal, delta|text}`
+Reasoning: `session.reasoning.started|delta|ended` (same shape, `ended` has full `text`)
+Tools: `session.tool.input.started {id,name}`, `.input.delta`, `.input.ended {text}`,
+`.called {id, input, executed}`, `.progress`, `.success {id, content:[Tool.Content], metadata, executed}`, `.failed`
+Usage: `session.usage.updated {sessionID, cost, tokens}`, `.usage.recorded`
+Inbox: `session.inbox.enqueued {inboxID, sessionID, item}`, `.delivered`, `.cancelled`, `.delivery.changed`
+Lifecycle: `session.created`, `session.updated`, `session.deleted`, `session.renamed`, `session.moved`, `session.forked`, `session.viewed`, `session.metadata.updated`
+Selections: `session.agent.selected`, `session.model.selected`
+Compaction: `session.compaction.started|delta|ended|failed`
+Shell: `session.shell.started|ended`
+Other: `session.skill.activated`, `session.retry.scheduled`, `session.message.content.updated`,
+`session.instructions.updated`, `session.error`, `project.updated`, `server.connected`
+Permissions: `permission.v2.asked`, `permission.v2.replied` (some v2 builds also emit `permission.asked` / `permission.replied`; the translator accepts both shapes).
+Forms: `form.created {form:Form.Info}`, `form.replied {id,sessionID,answer}`, `form.cancelled {id,sessionID}`
+
+## Verified probe notes
+
+- `POST /api/session {title}` → `{data: Session.Info}` (probe: sid `ses_...`).
+- Prompt POST returns `{data: userMessage}` immediately (fire-and-forget like v1).
+- `GET /api/session/{id}/message` returns newest-first (`order` param available);
+ includes `idle` messages (`{type:"idle", outcome:"succeeded"}`).
+- SSE line format: `data: {"id":"evt_...","type":"...","data":{...}}`, `: heartbeat` comments.
+- Model set probe `{providerID, modelID}` at top level → 400; correct body is `{model:{id, providerID}}`.
+
+## v1 servers (opencode <= 1.18.x) — verified against 1.18.32
+
+`npm install -g opencode-ai` still installs the v1 line (`latest` = 1.18.32 as of
+2026-09-24), so v1 servers are the common case, not a legacy edge case. The v1
+transport lives in `src/lib/sdk-v1.ts` and `src/lib/api-detect.ts` picks it.
+
+Detection is by **content type, never status code** — an unknown route on either
+server falls through to the web SPA and answers `200 text/html`:
+
+| probe | v2.0.16 | 1.18.32 |
+| --- | --- | --- |
+| `GET /api/info` | `401` unauthenticated / `200 application/json` with `{version,pid,urls,paths}` | `200 text/html` (SPA) |
+| `GET /global/health` | `200 text/html` (SPA) | `200 application/json` `{healthy,version}` |
+
+Live-verified v1 behaviour (all through `src/lib/sdk-v1.ts` against a local
+`opencode serve` on 1.18.32):
+
+- `GET /global/health` → `{"healthy":true,"version":"1.18.32"}`.
+- `/session`, `/session/{id}`, `POST /session`, `PATCH /session/{id}`,
+ `DELETE /session/{id}`, `/session/{id}/message`, `/session/{id}/abort`,
+ `/session/{id}/prompt_async`, `/experimental/session`, `/question`,
+ `/permission`, `/agent`, `/command`, `/provider`, `/path`, `/project/current`
+ all answer JSON as the pre-v2 client expects.
+- `/file/roots` is already gone in 1.18.32 → falls through to the SPA, which is
+ why `file.roots()` must keep tolerating a non-JSON answer instead of assuming 404.
+- SSE line format: `data: {"payload":{"id":"evt_...","type":"...","properties":{...}}}`
+ — the event is wrapped in `payload` (v2 has no wrapper), and `events.ts` unwraps
+ with `event.payload || event`. Heartbeats arrive as `server.heartbeat` events
+ rather than v2's `: heartbeat` comment frames.
diff --git a/package-lock.json b/package-lock.json
index 7a846b5c..0ca4b708 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -35,6 +35,7 @@
"react-i18next": "^17.0.10",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
+ "react-native-keyboard-controller": "^1.22.5",
"react-native-marked": "8.0.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
@@ -8515,6 +8516,20 @@
"react-native": "*"
}
},
+ "node_modules/react-native-keyboard-controller": {
+ "version": "1.22.5",
+ "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.22.5.tgz",
+ "integrity": "sha512-JS989QM4OpO17WmxoG/TK734ypfCL2ypMS1lClN7HAP0fNccfXkF+oIKhw25QDXOBZIcd/jhnLmoel7LFh1z9g==",
+ "license": "MIT",
+ "dependencies": {
+ "react-native-is-edge-to-edge": "^1.2.1"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*",
+ "react-native-reanimated": ">=3.0.0"
+ }
+ },
"node_modules/react-native-marked": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/react-native-marked/-/react-native-marked-8.0.0.tgz",
diff --git a/package.json b/package.json
index b24a7338..a51aaf0b 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,7 @@
"android": "expo run:android",
"typecheck": "tsc --noEmit",
"check:versions": "node scripts/check-version-parity.mjs",
- "test": "node --test 'src/**/*.test.ts' 'scripts/*.test.mjs'"
+ "test": "node --test 'src/**/*.test.ts' 'scripts/*.test.mjs' 'plugins/*.test.js'"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
@@ -40,6 +40,7 @@
"react-i18next": "^17.0.10",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
+ "react-native-keyboard-controller": "^1.22.5",
"react-native-marked": "8.0.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
diff --git a/plugins/withKeyboardInsets.js b/plugins/withKeyboardInsets.js
new file mode 100644
index 00000000..295dc944
--- /dev/null
+++ b/plugins/withKeyboardInsets.js
@@ -0,0 +1,38 @@
+const { withMainActivity } = require("@expo/config-plugins")
+
+const imports = `import android.view.View
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+`
+
+const listener = `
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+ val root = findViewById(android.R.id.content)
+ val bottom = root.paddingBottom
+ // Android 15+ no longer resizes edge-to-edge windows for the IME.
+ ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
+ view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, bottom + insets.getInsets(WindowInsetsCompat.Type.ime()).bottom)
+ insets
+ }
+ ViewCompat.requestApplyInsets(root)
+ }
+`
+
+function addKeyboardInsets(contents) {
+ if (contents.includes("WindowInsetsCompat.Type.ime()")) return contents
+ if (!contents.includes("import android.os.Bundle\n") || !contents.includes(" super.onCreate(null)\n")) {
+ throw new Error("Unsupported MainActivity.kt template")
+ }
+ return contents
+ .replace("import android.os.Bundle\n", `import android.os.Bundle\n${imports}`)
+ .replace(" super.onCreate(null)\n", ` super.onCreate(null)\n${listener}`)
+}
+
+module.exports = (config) =>
+ withMainActivity(config, (config) => {
+ if (config.modResults.language !== "kt") throw new Error("Keyboard inset fix requires MainActivity.kt")
+ config.modResults.contents = addKeyboardInsets(config.modResults.contents)
+ return config
+ })
+
+module.exports.addKeyboardInsets = addKeyboardInsets
diff --git a/plugins/withKeyboardInsets.test.js b/plugins/withKeyboardInsets.test.js
new file mode 100644
index 00000000..79ab8ace
--- /dev/null
+++ b/plugins/withKeyboardInsets.test.js
@@ -0,0 +1,10 @@
+const assert = require("node:assert/strict")
+const test = require("node:test")
+const { addKeyboardInsets } = require("./withKeyboardInsets")
+
+test("adds Android IME insets listener once", () => {
+ const source = "import android.os.Bundle\nclass MainActivity {\n super.onCreate(null)\n}\n"
+ const result = addKeyboardInsets(source)
+ assert.match(result, /WindowInsetsCompat\.Type\.ime\(\)/)
+ assert.equal(addKeyboardInsets(result), result)
+})
diff --git a/src/components/chat/DiffView.tsx b/src/components/chat/DiffView.tsx
index 00c66c09..32cc75ec 100644
--- a/src/components/chat/DiffView.tsx
+++ b/src/components/chat/DiffView.tsx
@@ -1,5 +1,5 @@
-import { View, Text, StyleSheet, Platform, ScrollView } from "react-native"
-import { WIDE_CONTENT_SCROLL_CONFIG } from "../../lib/scroll-config"
+import { View, Text, StyleSheet, Platform } from "react-native"
+import { wideTextSelection } from "../../lib/scroll-config"
import { computeDiff } from "./diff-compute"
const mono = Platform.OS === "ios" ? "Menlo" : "monospace"
@@ -12,40 +12,41 @@ interface Props {
export function DiffView({ before, after, isDark }: Props) {
const lines = computeDiff(before, after)
+ const sel = wideTextSelection(Platform.OS)
if (lines.length === 0) return null
return (
-
-
-
- {lines.map((line, idx) => (
-
-
- {line.type === "add" ? "+" : line.type === "remove" ? "-" : " "}
-
-
- {line.text}
-
-
- ))}
+
+ {lines.map((line, idx) => (
+
+
+ {line.type === "add" ? "+" : line.type === "remove" ? "-" : " "}
+
+ {/* s.text is flex:1 with no numberOfLines, so a long diff line folds
+ inside its own row: the +/- gutter stays aligned with the line it
+ belongs to, and nothing is truncated (issue #21). Wrapping is
+ rendering only — the diff text itself is untouched. */}
+
+ {line.text.length > 0 ? line.text : " "}
+
-
+ ))}
)
}
@@ -79,6 +80,9 @@ const s = StyleSheet.create({
prefixDark: { color: "#666666" },
text: {
+ // flex:1 is what makes the fold happen inside the row instead of pushing the
+ // row wider than the screen.
+ flex: 1,
fontSize: 12,
fontFamily: mono,
color: "#0a0a0a",
diff --git a/src/components/chat/MessageBubble.tsx b/src/components/chat/MessageBubble.tsx
index 0f82ecaf..6fec4967 100644
--- a/src/components/chat/MessageBubble.tsx
+++ b/src/components/chat/MessageBubble.tsx
@@ -1,10 +1,11 @@
import { memo } from "react"
-import { View, Text, Image, StyleSheet, ScrollView, TouchableOpacity, Dimensions } from "react-native"
+import { View, Text, Image, StyleSheet, ScrollView, TouchableOpacity, Dimensions, Platform } from "react-native"
import { Ionicons } from "@expo/vector-icons"
import { Markdown } from "../markdown"
import { ToolCallCard } from "./ToolCallCard"
import { ReasoningBlock } from "./ReasoningBlock"
import type { Message, Part } from "../../lib/sdk"
+import { hasSelectableContent } from "../../lib/message-text"
const SCREEN_WIDTH = Dimensions.get("window").width
@@ -16,16 +17,20 @@ interface Props {
message: Message
parts: Part[]
isDark: boolean
- // Only wired up for user messages — long-press opens the "Edit message" /
- // revert action sheet. Identified by messageID (not a closure over parts)
- // so it stays correct even if the memo below bails on a stale render.
- onLongPress?: (messageID: string) => void
+ // Message actions arrive as buttons in the bubble header (see the header
+ // comment in the render). There is deliberately no bubble-wide press or
+ // long-press: it competed with horizontal scrolling in code blocks and with
+ // text selection, and holding a message no longer pops anything. Handlers are
+ // identified by messageID (not a closure over parts) so they stay correct even
+ // if the memo below bails on a stale render.
+ onSelectText?: (messageID: string) => void
+ onEdit?: (messageID: string) => void
}
// TODO: Replace with streamdown-rn once React 19 types PR lands - it has
// built-in block-level memoization that eliminates re-renders for stable blocks
export const MessageBubble = memo(
- function MessageBubble({ message, parts, isDark, onLongPress }: Props) {
+ function MessageBubble({ message, parts, isDark, onSelectText, onEdit }: Props) {
const isUser = message.role === "user"
const textParts = parts.filter((p) => p.type === "text")
@@ -36,10 +41,7 @@ export const MessageBubble = memo(
const reasoning = reasoningParts.map((p) => p.text).join("\n") || ""
return (
- onLongPress(message.id) : undefined}
- disabled={!isUser || !onLongPress}
+ {isUser ? "You" : "Assistant"}
{message.model && {message.model.modelID}}
{!isUser && message.modelID && {message.modelID}}
+ {/* Message actions are taps in a right-aligned group at the end of the
+ header row — "Select text" sits immediately left of "Edit". Nothing
+ here is a bubble-wide gesture: a long-press on the bubble competed
+ with horizontal scrolling in code blocks and with text selection.
+ Labels are explicitly non-selectable so a tap can't be swallowed. */}
+ {(onSelectText || onEdit) && (
+
+ {onSelectText && hasSelectableContent(parts) && (
+ onSelectText(message.id)}
+ testID={`message-select-text-${message.id}`}
+ >
+
+ Select text
+
+
+ )}
+ {isUser && onEdit && (
+ onEdit(message.id)}
+ testID={`message-edit-${message.id}`}
+ >
+
+ Edit
+
+
+ )}
+
+ )}
{/* Image attachments */}
@@ -87,7 +122,10 @@ export const MessageBubble = memo(
{/* Message text */}
{text.length > 0 &&
(isUser ? (
-
+ // Android: inline selection in a list row is unreliable (RN#46999)
+ // and competes with the bubble's long-press, so user text selects
+ // through the "Select text" sheet. iOS has no such conflict.
+
{text}
) : (
@@ -101,6 +139,18 @@ export const MessageBubble = memo(
))}
+ {/* Run-level failure (assistant message carries `error` when the agent
+ errored out — provider failure, aborted stream, etc.). Without this
+ an errored run looked identical to a successful one when reading
+ history; the live case is covered by the session error banner. */}
+ {message.error?.message && (
+
+
+ {message.error.message}
+
+
+ )}
+
{/* Tokens/cost for assistant messages */}
{!isUser && message.tokens && (
@@ -108,7 +158,7 @@ export const MessageBubble = memo(
{message.cost ? ` · $${message.cost.toFixed(4)}` : ""}
)}
-
+
)
},
(prev, next) => {
@@ -120,7 +170,8 @@ export const MessageBubble = memo(
// unchanged (completed) messages during other messages' streaming.
if (prev.message !== next.message) return false
if (prev.isDark !== next.isDark) return false
- if (prev.onLongPress !== next.onLongPress) return false
+ if (prev.onSelectText !== next.onSelectText) return false
+ if (prev.onEdit !== next.onEdit) return false
if (prev.parts.length !== next.parts.length) return false
for (let i = 0; i < prev.parts.length; i++) {
if (prev.parts[i] !== next.parts[i]) return false
@@ -141,6 +192,27 @@ const s = StyleSheet.create({
roleUser: { color: "#0a0a0a" },
textWhite: { color: "#ffffff" },
+ actions: {
+ // Pushes the group to the right edge of the header row; the chips inside it
+ // stay adjacent in source order (Select text, then Edit).
+ marginLeft: "auto",
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ flexShrink: 0,
+ },
+ selectChip: {
+ paddingHorizontal: 8,
+ paddingVertical: 3,
+ borderRadius: 10,
+ borderWidth: 1,
+ borderColor: "#d4d4d4",
+ flexShrink: 0,
+ },
+ selectChipDark: { borderColor: "#404040" },
+ selectChipText: { fontSize: 11, color: "#525252" },
+ selectChipTextDark: { color: "#d4d4d4" },
+
modelTag: {
fontSize: 11,
color: "#999999",
@@ -158,6 +230,19 @@ const s = StyleSheet.create({
tokens: { fontSize: 11, color: "#999999", marginTop: 8 },
tokensDark: { color: "#666666" },
+ // Run-level error block inside an assistant bubble
+ errorBlock: {
+ marginTop: 8,
+ padding: 10,
+ borderRadius: 8,
+ backgroundColor: "#fef2f2",
+ borderWidth: 1,
+ borderColor: "#fecaca",
+ },
+ errorBlockDark: { backgroundColor: "#3b1212", borderColor: "#7f1d1d" },
+ errorBlockText: { color: "#b91c1c", fontSize: 13 },
+ errorBlockTextDark: { color: "#fca5a5" },
+
// Images
imageScroll: { marginBottom: 8 },
imageRow: { gap: 8 },
diff --git a/src/components/chat/MessageTextSheet.tsx b/src/components/chat/MessageTextSheet.tsx
new file mode 100644
index 00000000..9c6069e9
--- /dev/null
+++ b/src/components/chat/MessageTextSheet.tsx
@@ -0,0 +1,79 @@
+import { useState } from "react"
+import { Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View, useColorScheme } from "react-native"
+import * as Clipboard from "expo-clipboard"
+import { useTranslation } from "react-i18next"
+
+interface Props {
+ visible: boolean
+ text: string
+ onClose: () => void
+}
+
+// Full-screen selectable-text view for a chat message.
+//
+// Why a modal instead of selectable inside the bubble: markdown text
+// nodes live in rows of the session screen's FlatList, where selectable Text
+// hits the long-standing Android nesting bug (facebook/react-native#46999 —
+// the reason Markdown.tsx drops `selectable` entirely). A plain ScrollView
+// outside any virtualized list lets the platform's native selection
+// (long-press → handles → copy) work reliably on both platforms, and gives a
+// "copy all" fallback for whole-message copying.
+export function MessageTextSheet({ visible, text, onClose }: Props) {
+ const { t } = useTranslation()
+ const isDark = useColorScheme() === "dark"
+ const [copied, setCopied] = useState(false)
+
+ const copyAll = async () => {
+ await Clipboard.setStringAsync(text)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+
+ return (
+
+
+
+ {t("session.actions.selectText")}
+
+ {copied ? t("session.actions.copied") : t("session.actions.copyAll")}
+
+
+ {t("common.close")}
+
+
+
+
+ {text}
+
+
+
+
+ )
+}
+
+const s = StyleSheet.create({
+ container: { flex: 1, backgroundColor: "#fafafa" },
+ containerDark: { backgroundColor: "#111111" },
+ header: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 12,
+ paddingHorizontal: 16,
+ paddingTop: 60,
+ paddingBottom: 12,
+ borderBottomWidth: 1,
+ borderBottomColor: "#e5e5e5",
+ },
+ headerDark: { borderBottomColor: "#2a2a2a" },
+ title: { flex: 1, fontSize: 16, fontWeight: "600", color: "#0a0a0a" },
+ textWhite: { color: "#ffffff" },
+ copyBtn: { paddingHorizontal: 10, paddingVertical: 6, backgroundColor: "#e8e5f0", borderRadius: 8 },
+ copyBtnDark: { backgroundColor: "#2a2040" },
+ copyText: { color: "#6d28d9", fontSize: 13, fontWeight: "600" },
+ copyTextDark: { color: "#c4b5fd" },
+ closeBtn: { paddingHorizontal: 10, paddingVertical: 6 },
+ closeText: { color: "#666666", fontSize: 13 },
+ closeTextDark: { color: "#888888" },
+ scroll: { padding: 16 },
+ body: { fontSize: 15, lineHeight: 23, color: "#0a0a0a" },
+})
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/components/chat/index.ts b/src/components/chat/index.ts
index 4df6472f..b22b3d3d 100644
--- a/src/components/chat/index.ts
+++ b/src/components/chat/index.ts
@@ -12,3 +12,4 @@ export { ImageAttachments, type Attachment } from "./ImageAttachments"
export { DirectorySwitcher } from "./DirectorySwitcher"
export { DirectoryBrowserSheet } from "./DirectoryBrowserSheet"
export { SessionInfo } from "./SessionInfo"
+export { MessageTextSheet } from "./MessageTextSheet"
diff --git a/src/components/markdown/CodeBlock.tsx b/src/components/markdown/CodeBlock.tsx
index 8265309f..37080f57 100644
--- a/src/components/markdown/CodeBlock.tsx
+++ b/src/components/markdown/CodeBlock.tsx
@@ -1,7 +1,7 @@
import { useState } from "react"
-import { View, Text, TouchableOpacity, StyleSheet, useColorScheme, Platform, ScrollView } from "react-native"
+import { View, Text, TouchableOpacity, StyleSheet, useColorScheme, Platform } from "react-native"
import * as Clipboard from "expo-clipboard"
-import { WIDE_CONTENT_SCROLL_CONFIG } from "../../lib/scroll-config"
+import { wideTextSelection } from "../../lib/scroll-config"
interface Props {
code: string
@@ -11,6 +11,15 @@ interface Props {
export function CodeBlock({ code, language }: Props) {
const isDark = useColorScheme() === "dark"
const [copied, setCopied] = useState(false)
+ const sel = wideTextSelection(Platform.OS)
+
+ // Cosmetic wrapping: one Text per logical line, so indentation survives and a
+ // long line folds inside the block instead of needing sideways scrolling.
+ // The clipboard gets `code` — the raw string from the server — so what you
+ // paste is byte-identical to what was sent, line breaks and all. Wrapping only
+ // exists in the rendering. See ../../lib/scroll-config for why these blocks no
+ // longer scroll horizontally.
+ const lines = code.split("\n")
const copy = async () => {
try {
@@ -28,11 +37,15 @@ export function CodeBlock({ code, language }: Props) {
{copied ? "Copied!" : "Copy"}
-
-
- {code}
-
-
+
+ {lines.map((line, idx) => (
+ // An empty Text collapses to zero height, which would eat blank lines
+ // and make pasted-vs-displayed line counts disagree.
+
+ {line.length > 0 ? line : " "}
+
+ ))}
+
)
}
@@ -75,7 +88,7 @@ const styles = StyleSheet.create({
copyBtnDark: {
color: "#a78bfa",
},
- codeScroll: {
+ codeBody: {
padding: 12,
},
code: {
diff --git a/src/components/message-bubble-selection.regression.test.ts b/src/components/message-bubble-selection.regression.test.ts
new file mode 100644
index 00000000..1c717038
--- /dev/null
+++ b/src/components/message-bubble-selection.regression.test.ts
@@ -0,0 +1,94 @@
+import { test } from "node:test"
+import assert from "node:assert/strict"
+import { readFileSync } from "node:fs"
+import { fileURLToPath } from "node:url"
+import path from "node:path"
+
+// Same approach as wide-content-scroll.regression.test.ts: this repo's test
+// runtime is node:test with no React Native renderer, so assert on the real
+// component source instead of skipping component-level coverage.
+//
+// The reported regression: dragging sideways inside a code block selected the
+// message instead of scrolling it, and holding a message popped a menu. Both
+// came from the bubble being a touchable: upstream long-pressed USER bubbles
+// only (for Edit), selection support widened that to every bubble, and a slow
+// horizontal drag inside a bubble stays inside its bounds — so the touchable
+// keeps the press. Message actions are now buttons in the header row and the
+// bubble itself is a plain View with no press handling at all.
+
+const here = path.dirname(fileURLToPath(import.meta.url))
+const read = (rel: string) => readFileSync(path.join(here, rel), "utf8")
+
+test("the bubble has no press or long-press of its own", () => {
+ const src = read("chat/MessageBubble.tsx")
+ assert.match(src, /]*style=\{\[\s+s\.bubble/, "the bubble must be a plain View")
+ assert.doesNotMatch(src, /onPress=\{onLongPress|handleMessageLongPress/, "no long-press handler left")
+})
+
+test("both roles get header buttons, adjacent and right-aligned", () => {
+ const src = read("chat/MessageBubble.tsx")
+ assert.match(src, /onSelectText\?: \(messageID: string\) => void/)
+ assert.match(src, /onEdit\?: \(messageID: string\) => void/)
+ assert.match(src, /\{onSelectText && hasSelectableContent\(parts\) && \(/)
+ assert.match(src, /\{isUser && onEdit && \(/)
+ assert.match(src, /testID=\{`message-select-text-\$\{message\.id\}`\}/)
+ assert.match(src, /testID=\{`message-edit-\$\{message\.id\}`\}/)
+ assert.match(src, /onPress=\{\(\) => onSelectText\(message\.id\)\}/)
+ assert.match(src, /onPress=\{\(\) => onEdit\(message\.id\)\}/)
+
+ // "Select text" sits immediately left of "Edit": source order inside one
+ // group, and the group (not each chip) owns the right-alignment.
+ assert.ok(
+ src.indexOf("message-select-text") < src.indexOf("message-edit"),
+ "Select text must precede Edit in the group",
+ )
+ assert.match(src, //)
+ const groupStyle = src.slice(src.indexOf("actions: {"), src.indexOf("selectChip: {"))
+ assert.ok(groupStyle.includes('marginLeft: "auto"'), "the group is pushed to the right edge")
+ assert.ok(groupStyle.includes('flexDirection: "row"') && groupStyle.includes("gap: 6"), "chips sit in one row")
+ assert.ok(groupStyle.includes("flexShrink: 0"), "the chips must not be squeezed out by a long model tag")
+ const chipStyle = src.slice(src.indexOf("selectChip: {"), src.indexOf("selectChipDark:"))
+ assert.ok(!chipStyle.includes("marginLeft"), "chips stay adjacent; the group aligns them")
+
+ // A button label that is itself selectable text is how a tap turns into a
+ // selection instead of an action.
+ const chipLabels = src.split("\n").filter((line) => / {
+ const src = read("chat/MessageBubble.tsx")
+ assert.match(src, /selectable=\{Platform\.OS === "ios"\}/)
+ // No always-on selectable anywhere in the bubble body.
+ const code = src
+ .split("\n")
+ .filter((line) => !line.trim().startsWith("//"))
+ .join("\n")
+ .replace(/\{\/\*[\s\S]*?\*\/\}/g, "") // JSX block comments mention selection
+ .replaceAll('selectable={Platform.OS === "ios"}', "")
+ .replaceAll("selectable={false}", "")
+ assert.ok(!code.includes("selectable"), "bubble text selects only through the sheet on Android")
+})
+
+test("the session screen wires the buttons to single-implementation handlers", () => {
+ const src = readFileSync(path.join(here, "..", "..", "app", "session", "[id].tsx"), "utf8")
+ assert.match(src, /onSelectText=\{openSelectTextSheet\}/)
+ assert.match(src, /onEdit=\{startEditFlow\}/)
+ assert.doesNotMatch(src, /handleMessageLongPress/, "the long-press action sheet is gone")
+ assert.doesNotMatch(src, /type AlertButton/, "unused with the sheet removed")
+ assert.doesNotMatch(src, /import \{ hasSelectableContent, messagePartsToSelectableText \}/)
+ // No half-migrated leftover: the sheet text and the revert flow each live in
+ // exactly one place, shared by the buttons.
+ assert.doesNotMatch(src, /setTextSheet\(sheetText\)/)
+ assert.equal(src.split("revertToMessage(").length - 1, 1, "the revert flow must exist once, in startEditFlow")
+})
diff --git a/src/components/wide-content-scroll.regression.test.ts b/src/components/wide-content-scroll.regression.test.ts
deleted file mode 100644
index 3e0edcd2..00000000
--- a/src/components/wide-content-scroll.regression.test.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { test } from "node:test"
-import assert from "node:assert/strict"
-import { readFileSync } from "node:fs"
-import { fileURLToPath } from "node:url"
-import path from "node:path"
-
-// GitHub issue #21 ("QA: verify DiffView + CodeBlock horizontal-scroll
-// on-device with a populated diff"). The underlying fixes were:
-// - src/components/chat/DiffView.tsx: wrap diff lines in a horizontal
-// ScrollView (previously truncated with numberOfLines={1}).
-// - src/components/markdown/CodeBlock.tsx: wrap code in a horizontal
-// ScrollView (previously long lines wrapped/mangled).
-//
-// This repo's runtime is React Native, so these components can't be rendered
-// with node:test (no react-test-renderer / RN jest preset is set up here —
-// see package.json's "test" script, which only globs plain .test.ts files).
-// Instead of skipping component-level coverage, this test reads the actual
-// .tsx source and asserts on its structure: it's a plain-text/regex check,
-// but it directly targets the two markers that would prove a regression —
-// (a) the ScrollView wiring, (b) reintroducing line-truncation — so it can't
-// pass on a source file where the fix was reverted.
-
-function readComponent(relativePath: string): string {
- const dir = path.dirname(fileURLToPath(import.meta.url))
- return readFileSync(path.join(dir, relativePath), "utf8")
-}
-
-test("DiffView wraps diff lines in the shared horizontal-scroll ScrollView", () => {
- const src = readComponent("chat/DiffView.tsx")
- assert.match(src, / {
- const src = readComponent("markdown/CodeBlock.tsx")
- assert.match(src, / {
- const diffView = readComponent("chat/DiffView.tsx")
- const codeBlock = readComponent("markdown/CodeBlock.tsx")
- assert.match(diffView, /from ["']\.\.\/\.\.\/lib\/scroll-config["']/)
- assert.match(codeBlock, /from ["']\.\.\/\.\.\/lib\/scroll-config["']/)
-})
diff --git a/src/components/wide-content-wrap.regression.test.ts b/src/components/wide-content-wrap.regression.test.ts
new file mode 100644
index 00000000..1fb9cb96
--- /dev/null
+++ b/src/components/wide-content-wrap.regression.test.ts
@@ -0,0 +1,82 @@
+import { test } from "node:test"
+import assert from "node:assert/strict"
+import { readFileSync } from "node:fs"
+import { fileURLToPath } from "node:url"
+import path from "node:path"
+
+// GitHub issue #21 ("QA: verify DiffView + CodeBlock horizontal-scroll on-device
+// with a populated diff") was about wide lines being TRUNCATED. The original fix
+// was a horizontal ScrollView, which on a phone became a gesture fight against the
+// vertical message list (see src/lib/scroll-config.ts); both components now wrap
+// wide lines instead. What has to stay true is the original requirement plus the
+// copy guarantee:
+// (a) no numberOfLines / ellipsizeMode truncation in either component
+// (b) no horizontal scroll container reintroduced — that is the gesture bug
+// (c) CodeBlock copies the raw `code`, never the wrapped rendering
+//
+// This repo's runtime is React Native, so these components can't be rendered with
+// node:test (no react-test-renderer / RN jest preset — package.json's "test" script
+// only globs plain .test.ts files). Instead of skipping component-level coverage,
+// this test reads the actual .tsx source and asserts on the markers that would
+// prove a regression.
+
+const here = path.dirname(fileURLToPath(import.meta.url))
+const readComponent = (rel: string) => readFileSync(path.join(here, rel), "utf8")
+
+const WIDE_COMPONENTS = ["chat/DiffView.tsx", "markdown/CodeBlock.tsx"]
+
+// The rationale lives in comments, so comments must not be mistaken for code:
+// "numberOfLines", "horizontal" and "selectable" all appear in prose here.
+function codeOnly(src: string) {
+ return src
+ .split("\n")
+ .filter((line) => !line.trim().startsWith("//"))
+ .join("\n")
+ .replace(/\{\/\*[\s\S]*?\*\/\}/g, "")
+}
+
+test("wide content is never truncated", () => {
+ for (const rel of WIDE_COMPONENTS) {
+ const code = codeOnly(readComponent(rel))
+ assert.doesNotMatch(code, /numberOfLines/, `${rel} must not truncate wide lines (issue #21)`)
+ assert.doesNotMatch(code, /ellipsizeMode/, `${rel} must not ellipsize wide lines (issue #21)`)
+ }
+})
+
+test("wide content wraps instead of scrolling sideways", () => {
+ for (const rel of WIDE_COMPONENTS) {
+ const code = codeOnly(readComponent(rel))
+ assert.doesNotMatch(code, /ScrollView/, `${rel} must wrap wide lines, not scroll them sideways`)
+ assert.doesNotMatch(code, /horizontal/, `${rel} must not reintroduce a horizontal scroll container`)
+ }
+})
+
+test("every logical line is rendered, so indentation and blank lines survive", () => {
+ const codeBlock = readComponent("markdown/CodeBlock.tsx")
+ assert.ok(codeBlock.includes('code.split("\\n")'), "CodeBlock must render one Text per logical line")
+ assert.ok(codeBlock.includes('line.length > 0 ? line : " "'), "CodeBlock must keep blank lines visible")
+
+ const diffView = readComponent("chat/DiffView.tsx")
+ assert.ok(diffView.includes('line.text.length > 0 ? line.text : " "'), "DiffView must keep blank diff lines visible")
+ const textStyle = diffView.slice(diffView.indexOf(" text: {"))
+ assert.ok(textStyle.slice(0, 240).includes("flex: 1"), "DiffView text must be flex:1 so the fold happens inside the row")
+})
+
+test("copying a code block copies the raw string, not the wrapped rendering", () => {
+ const src = readComponent("markdown/CodeBlock.tsx")
+ assert.ok(src.includes("Clipboard.setStringAsync(code)"), "Copy must hand the clipboard the untouched code")
+ const code = codeOnly(src)
+ const writes = code.match(/setStringAsync\([^)]*\)/g) || []
+ assert.equal(writes.length, 1, "exactly one clipboard write, from the Copy button")
+ assert.equal(writes[0], "setStringAsync(code)", "the clipboard must receive the untouched string")
+})
+
+test("both components gate selection through the shared, unit-tested helper", () => {
+ for (const rel of WIDE_COMPONENTS) {
+ const src = readComponent(rel)
+ assert.ok(src.includes("wideTextSelection(Platform.OS)"), rel)
+ assert.ok(src.includes("selectable={sel.selectable}"), rel)
+ const code = codeOnly(src).replaceAll("selectable={sel.selectable}", "").replaceAll("sel.selectable", "")
+ assert.ok(!code.includes("selectable"), `${rel} must not enable selection outside wideTextSelection`)
+ }
+})
diff --git a/src/lib/api-detect.test.ts b/src/lib/api-detect.test.ts
new file mode 100644
index 00000000..04cd9700
--- /dev/null
+++ b/src/lib/api-detect.test.ts
@@ -0,0 +1,83 @@
+import assert from "node:assert/strict"
+import { test } from "node:test"
+
+import { apiForUnprobedServer, detectServerApi, isLegacyApi, type ProbeResponse } from "./api-detect.ts"
+
+// Every fixture below is a real response recorded on 2026-09-24 from a live server:
+// opencode v2.0.16 (100.77.13.76:4096) and opencode 1.18.32 (`opencode serve`).
+const SPA_HTML =
+ ' '
+
+function probe(responses: Record) {
+ return async (path: string): Promise => {
+ const found = responses[path]
+ if (!found) throw new Error(`probe not stubbed: ${path}`)
+ return found
+ }
+}
+
+test("detectServerApi reads a v2 server from its JSON /api/info", async () => {
+ const identity = await detectServerApi(
+ probe({
+ "/api/info": {
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ version: "2.0.16", pid: 2967, urls: ["http://100.77.13.76:4096"] }),
+ },
+ }),
+ )
+ assert.deepEqual(identity, { api: "v2", version: "2.0.16" })
+})
+
+test("detectServerApi still classifies v2 when /api/info only answers 401", async () => {
+ // Recorded unauthenticated against v2.0.16: the route exists and demands a password,
+ // while every v1 route on the same host falls through to the SPA.
+ const identity = await detectServerApi(
+ probe({
+ "/api/info": { status: 401, contentType: "", body: "" },
+ }),
+ )
+ assert.deepEqual(identity, { api: "v2" })
+})
+
+test("detectServerApi falls back to /global/health for a v1 server", async () => {
+ // Recorded against 1.18.32: /api/info is not a route there, so it serves the SPA
+ // with a 200. Treating that 200 as a healthy v2 connection is the bug this guards.
+ const identity = await detectServerApi(
+ probe({
+ "/api/info": { status: 200, contentType: "text/html", body: SPA_HTML },
+ "/global/health": { status: 200, contentType: "application/json", body: '{"healthy":true,"version":"1.18.32"}' },
+ }),
+ )
+ assert.deepEqual(identity, { api: "v1", version: "1.18.32" })
+})
+
+test("detectServerApi classifies v1 when health demands a password", async () => {
+ const identity = await detectServerApi(
+ probe({
+ "/api/info": { status: 200, contentType: "text/html", body: SPA_HTML },
+ "/global/health": { status: 401, contentType: "", body: "" },
+ }),
+ )
+ assert.deepEqual(identity, { api: "v1" })
+})
+
+test("detectServerApi rejects a server that answers HTML everywhere", async () => {
+ await assert.rejects(
+ detectServerApi(
+ probe({
+ "/api/info": { status: 200, contentType: "text/html", body: SPA_HTML },
+ "/global/health": { status: 200, contentType: "text/html", body: SPA_HTML },
+ }),
+ ),
+ /neither \/api\/info \(v2\) nor \/global\/health \(v1\)/,
+ )
+})
+
+test("isLegacyApi only reports v1", () => {
+ assert.equal(isLegacyApi("v1"), true)
+ assert.equal(isLegacyApi("v2"), false)
+ assert.equal(isLegacyApi(undefined), true)
+ assert.equal(apiForUnprobedServer(undefined), "v1")
+ assert.equal(apiForUnprobedServer("v2"), "v2")
+})
diff --git a/src/lib/api-detect.ts b/src/lib/api-detect.ts
new file mode 100644
index 00000000..c74bda9b
--- /dev/null
+++ b/src/lib/api-detect.ts
@@ -0,0 +1,85 @@
+// Decide which server API a connection speaks, from the response envelope alone.
+//
+// Verified against live servers on 2026-09-24:
+// opencode v2.0.16 GET /api/info -> 401 (route exists, auth required)
+// -> 200 application/json when authenticated
+// GET /global/health -> 200 text/html (SPA fall-through)
+// opencode 1.18.32 GET /api/info -> 200 text/html (SPA fall-through)
+// GET /global/health -> 200 application/json {healthy, version}
+//
+// The discriminator is the content type, never the status code: an unknown route on
+// either server answers 200 with the web SPA's HTML, which is exactly how a v1 server
+// used to pass v2's health check and then fail on every later request.
+//
+// Kept free of expo/fetch so it runs under plain `node --test` (same pattern as
+// api-error.ts / diagnostics-classify.ts).
+export type ServerApi = "v1" | "v2"
+
+export interface ServerIdentity {
+ api: ServerApi
+ version?: string
+}
+
+export interface ProbeResponse {
+ status: number
+ contentType: string
+ body: string
+}
+
+/** Fetch one path and report the envelope. Implemented by sdk.ts with expo/fetch. */
+export type ProbeRequest = (path: string) => Promise
+
+function isJson(contentType: string): boolean {
+ return contentType.includes("application/json")
+}
+
+/** 401/403 means the route exists but wants credentials — a route that does not
+ * exist would have fallen through to the SPA (200 HTML), so the API is known even
+ * though the body is not readable. */
+function wantsAuth(status: number): boolean {
+ return status === 401 || status === 403
+}
+
+function versionFrom(body: string): string | undefined {
+ try {
+ const parsed = JSON.parse(body) as { version?: unknown }
+ return typeof parsed.version === "string" ? parsed.version : undefined
+ } catch {
+ return undefined
+ }
+}
+
+/** Classify a server from the two probes. Throws when neither route answers as JSON,
+ * which means the endpoint is not an opencode server we can talk to. */
+export async function detectServerApi(probe: ProbeRequest): Promise {
+ const info = await probe("/api/info")
+ if (isJson(info.contentType) && info.status < 300) {
+ return { api: "v2", version: versionFrom(info.body) }
+ }
+ if (wantsAuth(info.status)) {
+ return { api: "v2" }
+ }
+
+ const health = await probe("/global/health")
+ if (isJson(health.contentType) && health.status < 300) {
+ return { api: "v1", version: versionFrom(health.body) }
+ }
+ if (wantsAuth(health.status)) {
+ return { api: "v1" }
+ }
+
+ throw new Error(
+ "Unsupported server: neither /api/info (v2) nor /global/health (v1) answered with JSON. " +
+ "This looks like an opencode web page, not a server API.",
+ )
+}
+
+/** True when a detected server is old enough that v2-only affordances must be hidden. */
+export function isLegacyApi(api: ServerApi | undefined): boolean {
+ return apiForUnprobedServer(api) === "v1"
+}
+
+/** Preserve the original API for saved connections until a probe can classify it. */
+export function apiForUnprobedServer(api: ServerApi | undefined): ServerApi {
+ return api ?? "v1"
+}
diff --git a/src/lib/composer-prompt.test.ts b/src/lib/composer-prompt.test.ts
new file mode 100644
index 00000000..cc949f96
--- /dev/null
+++ b/src/lib/composer-prompt.test.ts
@@ -0,0 +1,48 @@
+import assert from "node:assert/strict"
+import { createHash } from "node:crypto"
+import test from "node:test"
+import { buildPromptParts, legacyPromptBody, v2PromptBody } from "./composer-prompt.ts"
+
+const phrase = "Follow the spec 🙂\r\n```json\n{\"greeting\":\"héllo\",\"count\":100}\n```\ne\u0301 — preserve every line.\n"
+function promptOfCodePoints(length: number) {
+ const points = Array.from(phrase)
+ return Array.from({ length }, (_, index) => points[index % points.length]).join("")
+}
+
+function digest(value: string) {
+ return createHash("sha256").update(value, "utf8").digest("hex")
+}
+
+for (const size of [1_000, 5_000, 10_000, 25_000, 50_000, 100_000]) {
+ test(`preserves ${size.toLocaleString()} code points in v1 and v2 request bodies`, () => {
+ const original = promptOfCodePoints(size)
+ const parts = buildPromptParts(original)
+
+ const legacyWire = JSON.stringify(legacyPromptBody({ parts }))
+ const legacyRoundTrip = JSON.parse(legacyWire).parts[0].text as string
+ const v2Wire = JSON.stringify(v2PromptBody(parts))
+ const v2RoundTrip = JSON.parse(v2Wire).text as string
+
+ assert.equal(Array.from(original).length, size)
+ assert.equal(Array.from(legacyRoundTrip).length, size)
+ assert.equal(Array.from(v2RoundTrip).length, size)
+ assert.equal(digest(legacyRoundTrip), digest(original))
+ assert.equal(digest(v2RoundTrip), digest(original))
+ assert.equal(legacyRoundTrip, original)
+ assert.equal(v2RoundTrip, original)
+ })
+}
+
+test("preserves whitespace-only content when it is intentionally included", () => {
+ const parts = buildPromptParts(" \t\r\n ")
+ assert.deepEqual(parts, [{ type: "text", text: " \t\r\n " }])
+})
+
+test("keeps pasted text and attachments separate in both protocol bodies", () => {
+ const parts = buildPromptParts("exact text\n", [{ uri: "file:///photo.jpg", mime: "image/jpeg", filename: "photo.jpg" }])
+ assert.deepEqual(legacyPromptBody({ parts }), { parts })
+ assert.deepEqual(v2PromptBody(parts), {
+ text: "exact text\n",
+ files: [{ uri: "file:///photo.jpg", name: "photo.jpg" }],
+ })
+})
diff --git a/src/lib/composer-prompt.ts b/src/lib/composer-prompt.ts
new file mode 100644
index 00000000..60b16ba7
--- /dev/null
+++ b/src/lib/composer-prompt.ts
@@ -0,0 +1,48 @@
+export interface ComposerFile {
+ uri: string
+ mime: string
+ filename?: string
+ base64?: string
+}
+
+export type PromptPart =
+ | { type: "text"; text: string }
+ | { type: "file"; mime: string; url: string; filename?: string }
+
+/** Build server prompt parts without trimming, normalizing, or truncating text. */
+export function buildPromptParts(text: string, files: ComposerFile[] = []): PromptPart[] {
+ const parts: PromptPart[] = []
+ if (text.length > 0) parts.push({ type: "text", text })
+ for (const file of files) {
+ const url = file.base64 ? `data:${file.mime};base64,${file.base64}` : file.uri
+ parts.push({ type: "file", mime: file.mime, url, filename: file.filename })
+ }
+ return parts
+}
+
+export interface PromptSelection {
+ providerID: string
+ modelID: string
+}
+
+/** Exact JSON-compatible request body used by the v1 prompt_async endpoint. */
+export function legacyPromptBody(input: {
+ parts: PromptPart[]
+ model?: PromptSelection
+ agent?: string
+ variant?: string
+}) {
+ return input
+}
+
+/** Convert the mobile part model to the v2 prompt body without text rewriting. */
+export function v2PromptBody(parts: PromptPart[]) {
+ return {
+ text: parts.filter((part): part is Extract => part.type === "text")
+ .map((part) => part.text)
+ .join("\n"),
+ files: parts
+ .filter((part): part is Extract => part.type === "file")
+ .map((part) => ({ uri: part.url, ...(part.filename ? { name: part.filename } : {}) })),
+ }
+}
diff --git a/src/lib/diagnostics-classify.test.ts b/src/lib/diagnostics-classify.test.ts
index 16dee493..273dd8a7 100644
--- a/src/lib/diagnostics-classify.test.ts
+++ b/src/lib/diagnostics-classify.test.ts
@@ -135,7 +135,7 @@ test("root reachable but internet probe down still classifies as health-failed,
test("health probe 401 -> auth-failed, and the summary tells the user to fix credentials", () => {
const r = classify(
okUrl,
- probe({ ok: false, error: "HTTP 401", status: 401 }), // health
+ probe({ ok: false, error: "HTTP 401", status: 401, authProvided: true }), // health
probe({ ok: true }), // internet
probe({ ok: true, status: 401 }), // root reachable
)
@@ -146,8 +146,45 @@ test("health probe 401 -> auth-failed, and the summary tells the user to fix cre
assert.doesNotMatch(r.summary, /actually works/i)
})
+// The probe only sends Authorization when a password is filled in. A 401 with
+// NO credentials sent says nothing about the saved password — the user may
+// just not have typed it yet. That must not be reported as "the server
+// rejected your credentials", or they re-type a good password in a loop.
+test("health probe 401 with no credentials sent -> auth-required, not auth-failed", () => {
+ const r = classify(
+ okUrl,
+ probe({ ok: false, error: "HTTP 401", status: 401 }), // health, authProvided unset
+ probe({ ok: true }),
+ probe({ ok: true, status: 401 }),
+ )
+ assert.equal(r.classification, "auth-required")
+ assert.match(r.summary, /requires authentication/i)
+ assert.match(r.summary, /no credentials/i)
+ assert.doesNotMatch(r.summary, /rejected your credentials/i)
+})
+
+test("health probe 403 with no credentials sent -> auth-required too", () => {
+ const r = classify(
+ okUrl,
+ probe({ ok: false, status: 403 }),
+ probe({ ok: true }),
+ probe({ ok: true, status: 403 }),
+ )
+ assert.equal(r.classification, "auth-required")
+})
+
+test("explicit authProvided: false is treated the same as unset", () => {
+ const r = classify(
+ okUrl,
+ probe({ ok: false, status: 401, authProvided: false }),
+ probe({ ok: true }),
+ probe({ ok: true, status: 401 }),
+ )
+ assert.equal(r.classification, "auth-required")
+})
+
test("health probe 403 -> auth-failed too", () => {
- const r = classify(okUrl, probe({ ok: false, status: 403 }), probe({ ok: true }), probe({ ok: true, status: 403 }))
+ const r = classify(okUrl, probe({ ok: false, status: 403, authProvided: true }), probe({ ok: true }), probe({ ok: true, status: 403 }))
assert.equal(r.classification, "auth-failed")
assert.match(r.summary, /403/)
})
@@ -155,7 +192,7 @@ test("health probe 403 -> auth-failed too", () => {
test("auth-failed wins even when the server root probe is unreachable", () => {
// A 401 from /global/health already proves the server answered us; the
// root probe's outcome must not downgrade this to server-unreachable.
- const r = classify(okUrl, probe({ ok: false, status: 401 }), probe({ ok: true }), probe({ ok: false }))
+ const r = classify(okUrl, probe({ ok: false, status: 401, authProvided: true }), probe({ ok: true }), probe({ ok: false }))
assert.equal(r.classification, "auth-failed")
})
diff --git a/src/lib/diagnostics-classify.ts b/src/lib/diagnostics-classify.ts
index ff1be6d3..2cc7e936 100644
--- a/src/lib/diagnostics-classify.ts
+++ b/src/lib/diagnostics-classify.ts
@@ -7,6 +7,7 @@ export type Classification =
| "malformed-url"
| "no-internet"
| "server-unreachable"
+ | "auth-required"
| "auth-failed"
| "health-failed"
| "tls-error"
@@ -21,6 +22,7 @@ export interface ProbeAttempt {
durationMs: number
error?: string
errorCause?: string
+ authProvided?: boolean
}
export interface ParsedUrl {
@@ -67,6 +69,21 @@ export function classify(
// as ok:true and told the user "connection actually works now" while the
// password was wrong. `requireOk` (v0.4.8) stopped the lie; this makes the
// remaining message actionable so the user stops retrying blind.
+ // 401/403 with no credentials sent is a different problem from rejected
+ // credentials: the probe never authenticated, so the server's answer says
+ // nothing about the saved password. This happens when the user runs the
+ // diagnostic before typing the password (Quick Connect, empty field) —
+ // telling them "the server rejected your credentials" sends them
+ // re-typing a password that may be perfectly fine.
+ if ((health.status === 401 || health.status === 403) && !health.authProvided) {
+ return {
+ classification: "auth-required",
+ summary:
+ `The server is running and requires authentication (HTTP ${health.status}), ` +
+ `but this probe sent no credentials. Enter the server password — and the username too, ` +
+ `if the server sets OPENCODE_SERVER_USERNAME (it defaults to "opencode") — then retry.`,
+ }
+ }
if (health.status === 401 || health.status === 403) {
return {
classification: "auth-failed",
diff --git a/src/lib/diagnostics.ts b/src/lib/diagnostics.ts
index 1cac275b..2ed2fe5f 100644
--- a/src/lib/diagnostics.ts
+++ b/src/lib/diagnostics.ts
@@ -90,6 +90,7 @@ export async function probeConnection(url: string, auth?: { username: string; pa
timedFetch("server-root", `${base}/`, { headers }, { requireOk: false }),
timedFetch("internet", INTERNET_CHECK_URL),
])
+ health.authProvided = Boolean(auth?.password)
} else {
const skipped: ProbeAttempt = { name: "health", target: url, ok: false, durationMs: 0, error: "skipped: malformed url" }
health = skipped
diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json
index 13519f23..4f3e2427 100644
--- a/src/lib/i18n/en.json
+++ b/src/lib/i18n/en.json
@@ -6,6 +6,7 @@
"delete": "Delete",
"retry": "Retry",
"save": "Save",
+ "close": "Close",
"shareReport": "Share report"
},
"settings": {
@@ -81,7 +82,16 @@
"reconnecting": "Reconnecting… (attempt {{attempt}})",
"connected": "Connected ✓",
"reverted": "Message reverted — resend to confirm",
- "undo": "Undo"
+ "undo": "Undo",
+ "dismiss": "Dismiss"
+ },
+ "sheet": {
+ "thinking": "Thinking",
+ "tool": "Tool",
+ "input": "Input",
+ "result": "Result",
+ "error": "Error",
+ "file": "File"
},
"empty": {
"title": "Start a conversation",
@@ -95,7 +105,10 @@
},
"actions": {
"editMessage": "Edit message",
- "replace": "Replace"
+ "replace": "Replace",
+ "selectText": "Select text",
+ "copyAll": "Copy all",
+ "copied": "Copied"
},
"alerts": {
"messageActionsTitle": "Message actions",
diff --git a/src/lib/i18n/zh-Hans.json b/src/lib/i18n/zh-Hans.json
index 9ba906f7..6df27ae8 100644
--- a/src/lib/i18n/zh-Hans.json
+++ b/src/lib/i18n/zh-Hans.json
@@ -6,6 +6,7 @@
"delete": "删除",
"retry": "重试",
"save": "保存",
+ "close": "关闭",
"shareReport": "分享报告"
},
"settings": {
@@ -81,7 +82,16 @@
"reconnecting": "正在重新连接…(第 {{attempt}} 次尝试)",
"connected": "已连接 ✓",
"reverted": "消息已撤回 — 重新发送以确认",
- "undo": "撤销"
+ "undo": "撤销",
+ "dismiss": "忽略"
+ },
+ "sheet": {
+ "thinking": "思考过程",
+ "tool": "工具",
+ "input": "输入",
+ "result": "结果",
+ "error": "错误",
+ "file": "文件"
},
"empty": {
"title": "开始一段对话",
@@ -95,7 +105,10 @@
},
"actions": {
"editMessage": "编辑消息",
- "replace": "替换"
+ "replace": "替换",
+ "selectText": "选择文本",
+ "copyAll": "复制全部",
+ "copied": "已复制"
},
"alerts": {
"messageActionsTitle": "消息操作",
diff --git a/src/lib/message-text.test.ts b/src/lib/message-text.test.ts
new file mode 100644
index 00000000..790b1ee5
--- /dev/null
+++ b/src/lib/message-text.test.ts
@@ -0,0 +1,76 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+
+import { hasSelectableContent, messagePartsToSelectableText } from "./message-text.ts"
+
+import type { Part } from "./sdk.ts"
+
+test("flattens text, reasoning and tool parts in order", () => {
+ const parts: Part[] = [
+ { id: "t1", messageID: "m1", type: "text", text: "Working on it." },
+ { id: "r1", messageID: "m1", type: "reasoning", text: "Need to read the file first." },
+ {
+ id: "tool1",
+ messageID: "m1",
+ type: "tool",
+ tool: "read",
+ callID: "call_1",
+ state: { status: "completed", input: { path: "a.ts" }, output: "file contents" },
+ },
+ { id: "t2", messageID: "m1", type: "text", text: "Done." },
+ ]
+
+ const text = messagePartsToSelectableText(parts)
+ assert.equal(
+ text,
+ [
+ "Working on it.",
+ "Thinking:\nNeed to read the file first.",
+ 'Tool: read\nInput:\n{\n "path": "a.ts"\n}\nResult:\nfile contents',
+ "Done.",
+ ].join("\n\n"),
+ )
+})
+
+test("tool errors render as Error blocks", () => {
+ const parts: Part[] = [
+ {
+ id: "tool1",
+ messageID: "m1",
+ type: "tool",
+ tool: "bash",
+ state: { status: "error", input: { command: "exit 1" }, error: { message: "exit code 1" } },
+ },
+ ]
+
+ const text = messagePartsToSelectableText(parts)
+ assert.equal(text, "Tool: bash\nInput:\n{\n \"command\": \"exit 1\"\n}\nError:\nexit code 1")
+})
+
+test("custom labels are used (i18n)", () => {
+ const parts: Part[] = [{ id: "r1", messageID: "m1", type: "reasoning", text: "hmm" }]
+ const text = messagePartsToSelectableText(parts, {
+ thinking: "思考过程",
+ tool: "工具",
+ input: "输入",
+ result: "结果",
+ error: "错误",
+ file: "文件",
+ })
+ assert.equal(text, "思考过程:\nhmm")
+})
+
+test("empty and non-content parts produce empty text", () => {
+ const parts: Part[] = [
+ { id: "s1", messageID: "m1", type: "step-start" },
+ { id: "t1", messageID: "m1", type: "text", text: "" },
+ ]
+ assert.equal(messagePartsToSelectableText(parts), "")
+ assert.equal(hasSelectableContent(parts), false)
+})
+
+test("hasSelectableContent detects reasoning-only and tool-only messages", () => {
+ assert.equal(hasSelectableContent([{ id: "r", messageID: "m", type: "reasoning", text: "x" }]), true)
+ assert.equal(hasSelectableContent([{ id: "t", messageID: "m", type: "tool", tool: "read" }]), true)
+ assert.equal(hasSelectableContent([{ id: "f", messageID: "m", type: "file", filename: "shot.png" }]), true)
+})
diff --git a/src/lib/message-text.ts b/src/lib/message-text.ts
new file mode 100644
index 00000000..4f1ffb3b
--- /dev/null
+++ b/src/lib/message-text.ts
@@ -0,0 +1,76 @@
+import type { Part } from "./sdk"
+
+// Plain-text rendering of a message's parts for the full-screen selection
+// sheet. Selection needs a single selectable (FlatList rows break
+// Android selection — react-native#46999), so every part kind is flattened
+// into one text blob: assistant text verbatim, reasoning under a "Thinking"
+// heading, tool calls as name + input + result/error blocks.
+
+export type SheetLabels = {
+ thinking: string
+ tool: string
+ input: string
+ result: string
+ error: string
+ file: string
+}
+
+const DEFAULT_LABELS: SheetLabels = {
+ thinking: "Thinking",
+ tool: "Tool",
+ input: "Input",
+ result: "Result",
+ error: "Error",
+ file: "File",
+}
+
+function formatValue(value: unknown): string {
+ if (typeof value === "string") return value
+ try {
+ return JSON.stringify(value, null, 2) ?? String(value)
+ } catch {
+ return String(value)
+ }
+}
+
+export function messagePartsToSelectableText(parts: Part[], labels: SheetLabels = DEFAULT_LABELS): string {
+ const blocks: string[] = []
+ for (const part of parts) {
+ if (part.type === "text") {
+ if (part.text) blocks.push(part.text)
+ continue
+ }
+ if (part.type === "reasoning") {
+ if (part.text) blocks.push(`${labels.thinking}:\n${part.text}`)
+ continue
+ }
+ if (part.type === "tool") {
+ const lines = [`${labels.tool}: ${part.tool || part.callID || "unknown"}`]
+ if (part.state?.input !== undefined) {
+ lines.push(`${labels.input}:\n${formatValue(part.state.input)}`)
+ }
+ if (part.state?.status === "error") {
+ lines.push(`${labels.error}:\n${part.state.error?.message || "Tool call failed"}`)
+ } else if (part.state?.output !== undefined) {
+ lines.push(`${labels.result}:\n${formatValue(part.state.output)}`)
+ }
+ blocks.push(lines.join("\n"))
+ continue
+ }
+ if (part.type === "file" && part.filename) {
+ blocks.push(`${labels.file}: ${part.filename}`)
+ }
+ }
+ return blocks.join("\n\n")
+}
+
+// True when a message has any part worth opening the selection sheet for.
+export function hasSelectableContent(parts: Part[]): boolean {
+ return parts.some(
+ (part) =>
+ (part.type === "text" && !!part.text) ||
+ (part.type === "reasoning" && !!part.text) ||
+ part.type === "tool" ||
+ (part.type === "file" && !!part.filename),
+ )
+}
diff --git a/src/lib/notify-format.test.ts b/src/lib/notify-format.test.ts
index 65da5c25..07b9c7c7 100644
--- a/src/lib/notify-format.test.ts
+++ b/src/lib/notify-format.test.ts
@@ -1,6 +1,6 @@
import { test } from "node:test"
import assert from "node:assert/strict"
-import { sanitizeBody, MAX_NOTIF_BODY } from "./notify-format.ts"
+import { sanitizeBody, MAX_NOTIF_BODY, permissionNotificationBody, questionNotificationBody } from "./notify-format.ts"
// Notification bodies come from the server (and ultimately the AI). They must be
// neutralized before display: control chars stripped, whitespace trimmed, length capped.
@@ -39,3 +39,8 @@ test("trims before slicing so leading whitespace doesn't eat the budget", () =>
const out = sanitizeBody(" " + "y".repeat(250), "fb")
assert.equal(out, "y".repeat(MAX_NOTIF_BODY))
})
+
+test("permission and question notifications never expose server-supplied text on the lock screen", () => {
+ assert.equal(permissionNotificationBody(), "A tool needs your approval")
+ assert.equal(questionNotificationBody(), "The assistant has a question")
+})
diff --git a/src/lib/notify-format.ts b/src/lib/notify-format.ts
index 5a1e1496..5ce9f143 100644
--- a/src/lib/notify-format.ts
+++ b/src/lib/notify-format.ts
@@ -12,3 +12,13 @@ export const MAX_NOTIF_BODY = 200
export function sanitizeBody(s: string | undefined, fallback: string): string {
return (s ? s.replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, MAX_NOTIF_BODY) : "") || fallback
}
+
+/** Permission details are server-controlled and can contain sensitive paths. */
+export function permissionNotificationBody(): string {
+ return "A tool needs your approval"
+}
+
+/** Keep arbitrary server question text off the lock screen as well. */
+export function questionNotificationBody(): string {
+ return "The assistant has a question"
+}
diff --git a/src/lib/permissions.test.ts b/src/lib/permissions.test.ts
new file mode 100644
index 00000000..69eff995
--- /dev/null
+++ b/src/lib/permissions.test.ts
@@ -0,0 +1,44 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+import { mergePendingPermissions, normalizePermissionEvent, permissionReplyRequest } from "./permissions.ts"
+
+test("normalizes legacy permission asks", () => {
+ assert.deepEqual(normalizePermissionEvent({ type: "permission.asked", properties: {
+ id: "p1", sessionID: "s1", permission: "bash", patterns: ["printf hi"], metadata: {},
+ } }), { id: "p1", sessionID: "s1", permission: "bash", patterns: ["printf hi"], metadata: {} })
+})
+
+test("normalizes PermissionV2 action/resources/metadata/source", () => {
+ assert.deepEqual(normalizePermissionEvent({ type: "permission.v2.asked", properties: {
+ id: "p2", sessionID: "s1", action: "bash", resources: ["printf hi"], metadata: { command: "printf hi" }, source: { tool: "bash" },
+ } }), { id: "p2", sessionID: "s1", permission: "bash", patterns: ["printf hi"], action: "bash", resources: ["printf hi"], metadata: { command: "printf hi" }, source: { tool: "bash" } })
+})
+
+test("rejects malformed requests and ignores unrelated events", () => {
+ assert.equal(normalizePermissionEvent({ type: "permission.v2.asked", properties: { id: "p", sessionID: "s" } }), null)
+ assert.equal(normalizePermissionEvent({ type: "other", properties: {} }), null)
+})
+
+test("deduplicates replayed IDs and lets the latest request data win", () => {
+ const item = normalizePermissionEvent({ type: "permission.v2.asked", properties: {
+ id: "p", sessionID: "s", action: "bash", resources: ["safe"],
+ } })!
+ assert.deepEqual(mergePendingPermissions([item], [item]), [item])
+})
+
+for (const reply of ["once", "always", "reject"] as const) {
+ test(`builds the v1 and v2 ${reply} reply endpoint and wire value`, () => {
+ assert.deepEqual(permissionReplyRequest("v1", "per_1", reply), {
+ path: "/permission/per_1/reply",
+ body: { reply },
+ })
+ assert.deepEqual(permissionReplyRequest("v2", "per_1", reply, "ses_1"), {
+ path: "/api/session/ses_1/permission/per_1/reply",
+ body: { reply },
+ })
+ })
+}
+
+test("v2 reply refuses to form a global request without the owning session", () => {
+ assert.throws(() => permissionReplyRequest("v2", "per_1", "once"), /requires its sessionID/)
+})
diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts
new file mode 100644
index 00000000..27e5caa7
--- /dev/null
+++ b/src/lib/permissions.ts
@@ -0,0 +1,92 @@
+export type PendingPermission = {
+ id: string
+ sessionID: string
+ permission: string
+ patterns: string[]
+ metadata: Record
+ tool?: { messageID: string; callID: string }
+ action?: string
+ resources?: string[]
+ save?: string[]
+ source?: Record
+}
+
+export type PermissionReply = "once" | "always" | "reject"
+
+export function permissionReplyRequest(
+ protocol: "v1" | "v2",
+ requestID: string,
+ reply: PermissionReply,
+ sessionID?: string,
+): { path: string; body: { reply: PermissionReply } } {
+ if (protocol === "v2") {
+ if (!sessionID) throw new Error("A v2 permission reply requires its sessionID")
+ return {
+ path: `/api/session/${encodeURIComponent(sessionID)}/permission/${encodeURIComponent(requestID)}/reply`,
+ body: { reply },
+ }
+ }
+ return { path: `/permission/${encodeURIComponent(requestID)}/reply`, body: { reply } }
+}
+
+type PermissionEvent = {
+ type?: string
+ properties?: Record
+}
+
+/** Normalize both generations of server permission request into the prompt model. */
+export function normalizePermissionEvent(event: PermissionEvent): PendingPermission | null {
+ if (event.type !== "permission.asked" && event.type !== "permission.v2.asked") return null
+ const properties = event.properties
+ if (!properties || typeof properties.id !== "string" || !properties.id ||
+ typeof properties.sessionID !== "string" || !properties.sessionID) return null
+
+ if (event.type === "permission.asked") {
+ if (typeof properties.permission !== "string" || !Array.isArray(properties.patterns) ||
+ !properties.patterns.every((item) => typeof item === "string")) return null
+ return {
+ id: properties.id,
+ sessionID: properties.sessionID,
+ permission: properties.permission,
+ patterns: properties.patterns,
+ metadata: isRecord(properties.metadata) ? properties.metadata : {},
+ ...(isRecord(properties.tool) ? { tool: properties.tool as PendingPermission["tool"] } : {}),
+ }
+ }
+
+ if (typeof properties.action !== "string" || !Array.isArray(properties.resources) ||
+ !properties.resources.every((item) => typeof item === "string")) return null
+ return {
+ id: properties.id,
+ sessionID: properties.sessionID,
+ permission: properties.action,
+ patterns: properties.resources,
+ metadata: isRecord(properties.metadata) ? properties.metadata : {},
+ action: properties.action,
+ resources: properties.resources,
+ ...(Array.isArray(properties.save) && properties.save.every((item) => typeof item === "string")
+ ? { save: properties.save as string[] } : {}),
+ ...(isRecord(properties.source) ? { source: properties.source } : {}),
+ }
+}
+
+/** Convert a pending-list API item (which may be from either protocol generation). */
+export function normalizePendingPermission(value: unknown): PendingPermission | null {
+ if (!isRecord(value)) return null
+ return normalizePermissionEvent({
+ type: Array.isArray(value.resources) || typeof value.action === "string" ? "permission.v2.asked" : "permission.asked",
+ properties: value,
+ })
+}
+
+export function mergePendingPermissions(
+ current: PendingPermission[], incoming: PendingPermission[],
+): PendingPermission[] {
+ const merged = new Map(current.map((item) => [item.id, item]))
+ for (const item of incoming) merged.set(item.id, { ...merged.get(item.id), ...item })
+ return [...merged.values()]
+}
+
+function isRecord(value: unknown): value is Record {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+}
diff --git a/src/lib/persisted-keys.test.ts b/src/lib/persisted-keys.test.ts
index 2e1a07b0..a1c7faad 100644
--- a/src/lib/persisted-keys.test.ts
+++ b/src/lib/persisted-keys.test.ts
@@ -7,11 +7,11 @@ import path from "node:path"
* Tripwire for the "poisoned persistent memory" class of bug (a scanner
* flagged us for it; see the triage in the security notes).
*
- * The app is a thin client: sessions, messages and tool output live on the
- * opencode server and are held in memory by the zustand stores. Nothing that
- * a model or a tool produced is ever written to on-device storage, so there
- * is no persistent store an injected instruction could sit in and be replayed
- * from on the next launch.
+ * Sessions, messages and tool output live on the opencode server and are held
+ * in memory by the zustand stores. The one message-like exception is an
+ * unsubmitted composer draft: it is user-authored and stored encrypted in
+ * SecureStore so Android process recreation does not silently discard it.
+ * Model/tool output is never written to on-device storage.
*
* That property is worth keeping, and it is invisible in review: it only
* breaks the day someone adds a well-meaning "cache the last conversation" or
@@ -20,14 +20,16 @@ import path from "node:path"
* to this list and say what it holds, which is the moment to ask whether the
* value is user/config data or model output.
*
- * Rule: keys listed here must hold user-entered config, consent flags or
- * counters. Never message text, session titles, tool results or any other
- * model-derived content.
+ * Rule: keys listed here may hold user-entered config, consent flags, counters,
+ * or encrypted unsubmitted user-authored drafts. Never persist session
+ * transcripts, session titles, tool results or any model-derived content.
*/
const ALLOWED_PERSISTED_KEYS = new Map([
["SETTINGS_KEY", "user's app settings (theme, notification prefs)"],
["CONNECTIONS_KEY", "user-entered server connections"],
["`${PASSWORDS_PREFIX}${id}`", "user-entered server password, per connection"],
+ ["LAST_SESSION_KEY", "user's last selected session reference (ID and directory only)"],
+ ["writeSessionDraft(SecureStore, sessionID, text)", "encrypted, user-authored unsent composer draft, per session"],
["RECENT_DIRS_KEY", "directories the user picked, for the recents list"],
["AUTH_SETTINGS_KEY", "biometric/app-lock preference"],
["COUNT_KEY", "store-review: launch counter"],
@@ -60,6 +62,11 @@ test("every on-device write uses a reviewed key, never model-generated content",
unknown.push(`${path.relative(SRC, file)}: ${key}`)
}
}
+ if (code.includes("writeSessionDraft(SecureStore, sessionID, text)")) {
+ // This helper stores only user-authored composer drafts in SecureStore.
+ // It is intentionally a distinct, reviewed persistence path.
+ continue
+ }
}
assert.deepEqual(
@@ -78,6 +85,7 @@ test("the allowlist itself stays live (no keys left behind after a refactor)", (
const used = new Set(
[...code.matchAll(/SecureStore\.setItemAsync\(\s*([^,]+?)\s*,/g)].map((match) => match[1].trim()),
)
+ if (code.includes("writeSessionDraft(SecureStore, sessionID, text)")) used.add("writeSessionDraft(SecureStore, sessionID, text)")
const stale = [...ALLOWED_PERSISTED_KEYS.keys()].filter((key) => !used.has(key))
assert.deepEqual(stale, [], `Allowlist entries no longer written anywhere — delete them:\n${stale.join("\n")}`)
diff --git a/src/lib/scroll-config.test.ts b/src/lib/scroll-config.test.ts
index 8e1739e5..dc64bba7 100644
--- a/src/lib/scroll-config.test.ts
+++ b/src/lib/scroll-config.test.ts
@@ -1,26 +1,30 @@
import { test } from "node:test"
import assert from "node:assert/strict"
-import { WIDE_CONTENT_SCROLL_CONFIG } from "./scroll-config.ts"
+import { wideTextSelection } from "./scroll-config.ts"
-// GitHub issue #21: DiffView + CodeBlock must render wide content in a
-// horizontally-scrollable container (not wrapped, not truncated). Both
-// components spread WIDE_CONTENT_SCROLL_CONFIG onto their ScrollView (see
-// src/components/chat/DiffView.tsx and src/components/markdown/CodeBlock.tsx)
-// so asserting on this object here is asserting on the actual runtime props,
-// not a parallel copy that can drift out of sync.
+// wideTextSelection is the single place CodeBlock and DiffView decide whether
+// their text is inline-selectable. See src/lib/scroll-config.ts for the gesture
+// history behind the decision; wide-content-wrap.regression.test.ts is what proves
+// both components actually call this helper rather than keeping a private copy.
-test("wide-content scroll config enables horizontal scrolling", () => {
- assert.equal(WIDE_CONTENT_SCROLL_CONFIG.horizontal, true)
+test("android gets no inline selection in wide content", () => {
+ assert.deepEqual(wideTextSelection("android"), { selectable: false })
})
-test("wide-content scroll config shows the horizontal scroll indicator", () => {
- // Regression guard: a container that scrolls but hides its indicator is
- // easy to mistake for content that simply doesn't overflow. Keep the
- // indicator visible so on-device QA (and screenshots) can tell scrollable
- // content apart from clipped/truncated content.
- assert.equal(WIDE_CONTENT_SCROLL_CONFIG.showsHorizontalScrollIndicator, true)
+test("ios keeps inline selection (no gesture conflict there)", () => {
+ assert.deepEqual(wideTextSelection("ios"), { selectable: true })
})
-test("wide-content scroll config has no unexpected keys", () => {
- assert.deepEqual(Object.keys(WIDE_CONTENT_SCROLL_CONFIG).sort(), ["horizontal", "showsHorizontalScrollIndicator"])
+test("every other platform falls back to non-selectable", () => {
+ for (const platform of ["web", "windows", "macos", "unknown"]) {
+ assert.deepEqual(wideTextSelection(platform), { selectable: false }, platform)
+ }
+})
+
+test("the decision is only about selection", () => {
+ // Wrapping replaced horizontal scrolling, so there is no scroll knob left to
+ // freeze. `scrollEnabled` in this shape is what made the reverted 0.5.6 attempt
+ // trade one bug for a worse one; it must not come back.
+ assert.deepEqual(Object.keys(wideTextSelection("android")), ["selectable"])
+ assert.deepEqual(Object.keys(wideTextSelection("ios")), ["selectable"])
})
diff --git a/src/lib/scroll-config.ts b/src/lib/scroll-config.ts
index b915ede1..f1231702 100644
--- a/src/lib/scroll-config.ts
+++ b/src/lib/scroll-config.ts
@@ -1,18 +1,35 @@
-// Pure (no React Native imports) horizontal-scroll configuration shared by
-// src/components/chat/DiffView.tsx and src/components/markdown/CodeBlock.tsx.
+// Pure (no React Native imports) helpers shared by src/components/chat/DiffView.tsx
+// and src/components/markdown/CodeBlock.tsx. Keeping the one decision these two
+// components make in a plain module means it can be unit-tested with node:test
+// (this repo's test runtime has no React Native renderer) instead of being
+// duplicated in both files.
//
-// GitHub issue #21: wide diff lines and wide code-block lines must render in
-// a horizontally-scrollable container instead of being wrap-broken or
-// truncated with `numberOfLines`. Centralizing the actual runtime props in
-// one plain object lets that decision be unit-tested with node:test (no React
-// Native renderer needed) while both components spread the SAME object onto
-// their ScrollView, so the test and the real components can't drift apart.
-export interface HorizontalScrollConfig {
- horizontal: true
- showsHorizontalScrollIndicator: boolean
+// Wide content — diff lines and code lines — WRAPS instead of scrolling sideways.
+// The history of that decision is worth keeping, because every earlier attempt at
+// this regressed something else:
+//
+// - Issue #21 was about truncation: wide lines were being cut with numberOfLines.
+// Wrapping solves that just as a horizontal ScrollView did, without the gesture
+// problem below. Neither component may reintroduce numberOfLines or ellipsizeMode.
+// - A horizontal ScrollView inside the vertical message list is unreliable on
+// Android: ReactScrollView extends android.widget.ScrollView
+// (ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java:79),
+// so it only claims a drag it wins on Android's axis test against the enclosing
+// list — the drag must be horizontally dominant from the first ACTION_MOVE. That
+// ±45° window is native and no prop widens it: nestedScrollEnabled hands leftover
+// scroll to the parent, it does not change who claims the gesture. Paging buttons
+// were the workaround that existed until wrapping made them unnecessary.
+// - `selectable` competes with horizontal drags (facebook/react-native#24630
+// family) and is unreliable inside FlatList rows on Android
+// (facebook/react-native#46999) — the same reason Markdown.tsx renders prose
+// non-selectable. Selecting message text goes through MessageTextSheet, a plain
+// ScrollView outside the list. CodeBlock's Copy button copies the raw string, so
+// cosmetic wrapping never changes what lands on the clipboard.
+export interface WideTextSelection {
+ selectable: boolean
}
-export const WIDE_CONTENT_SCROLL_CONFIG: HorizontalScrollConfig = {
- horizontal: true,
- showsHorizontalScrollIndicator: true,
+export function wideTextSelection(platform: string): WideTextSelection {
+ // iOS has no gesture conflict here, so it keeps inline selection.
+ return { selectable: platform === "ios" }
}
diff --git a/src/lib/sdk-v1.ts b/src/lib/sdk-v1.ts
new file mode 100644
index 00000000..a776c04e
--- /dev/null
+++ b/src/lib/sdk-v1.ts
@@ -0,0 +1,560 @@
+// Legacy SDK client wrapper for React Native — opencode v1 server API (<= 1.18.x).
+// Restored from the pre-v2 client (origin/main) so the app still works against a
+// v1 server, which is what `npm install -g opencode-ai` installs today (1.18.32).
+//
+// Kept as its own module rather than a set of fallbacks inside sdk.ts: the two
+// APIs differ in routing prefix, list envelopes, SSE payload names and the
+// question/permission endpoints, so mixing them in one request path is where the
+// silent failures come from. sdk.ts probes a connection (api-detect.ts) and hands
+// it this client; the internal shapes this file returns are the same ones
+// v2-shape.ts converts v2 responses into, so stores and screens are unchanged.
+//
+// expo/fetch provides WinterCG-compliant fetch with ReadableStream support for SSE
+import { fetch as expoFetch } from "expo/fetch"
+import { buildRequestHeaders } from "./headers"
+import { SSEParser } from "./sse"
+import { apiErrorFor } from "./api-error"
+import { legacyPromptBody } from "./composer-prompt"
+import { permissionReplyRequest } from "./permissions"
+import { readWithIdleTimeout } from "./sse-lifecycle"
+import { loadSessionList } from "./session-list"
+import type { FileRoot } from "./file-roots"
+
+export { ApiAuthError, isAuthError } from "./api-error"
+
+export interface ClientConfig {
+ baseUrl: string
+ directory?: string
+ auth?: {
+ username: string
+ password: string
+ }
+}
+
+export interface Session {
+ id: string
+ slug: string
+ projectID: string
+ directory: string
+ parentID?: string
+ title: string
+ version: string
+ share?: { url: string }
+ time: {
+ created: number
+ updated: number
+ compacting?: number
+ archived?: number
+ }
+ summary?: {
+ additions: number
+ deletions: number
+ files: number
+ }
+ // Present while a message (and everything after it) is pending revert —
+ // the server keeps the underlying messages until the next prompt/summarize
+ // call runs cleanup (or the revert is undone via session.unrevert).
+ revert?: {
+ messageID: string
+ partID?: string
+ }
+}
+
+export interface Message {
+ id: string
+ sessionID: string
+ role: "user" | "assistant"
+ parentID?: string
+ time: {
+ created: number
+ completed?: number
+ }
+ // User message fields
+ agent?: string
+ model?: { providerID: string; modelID: string }
+ // Assistant message fields
+ modelID?: string
+ providerID?: string
+ cost?: number
+ tokens?: {
+ input: number
+ output: number
+ reasoning?: number
+ cache?: { read: number; write: number }
+ }
+ error?: { message: string }
+ finish?: string
+}
+
+// API returns messages with parts embedded
+export interface MessageWithParts {
+ info: Message
+ parts: Part[]
+}
+
+export interface Part {
+ id: string
+ sessionID?: string
+ messageID: string
+ type:
+ | "text"
+ | "reasoning"
+ | "tool"
+ | "file"
+ | "snapshot"
+ | "patch"
+ | "step-start"
+ | "step-finish"
+ | "subtask"
+ | "retry"
+ | "compaction"
+ | "agent"
+ // Text / reasoning part
+ text?: string
+ // Tool part
+ tool?: string
+ callID?: string
+ state?: {
+ status: "pending" | "running" | "completed" | "error"
+ input?: unknown
+ output?: unknown
+ title?: string
+ error?: { message: string }
+ time?: { start?: number; end?: number }
+ }
+ // Timing
+ time?: { start?: number; end?: number }
+ // File part
+ mime?: string
+ url?: string
+ filename?: string
+}
+
+export interface Agent {
+ name: string
+ description?: string
+ mode: "subagent" | "primary" | "all"
+ native?: boolean
+ hidden?: boolean
+ topP?: number
+ temperature?: number
+ color?: string
+ model?: { modelID: string; providerID: string }
+ prompt?: string
+ options: Record
+ steps?: number
+}
+
+export interface Command {
+ name: string
+ description?: string
+ agent?: string
+ model?: string
+ mcp?: boolean
+ template: string
+ subtask?: boolean
+ hints: string[]
+}
+
+export interface Project {
+ id: string
+ name?: string
+ path: {
+ cwd: string
+ root: string
+ absolute: string
+ }
+}
+
+export interface FileEntry {
+ name: string
+ path: string
+ absolute: string
+ type: "file" | "directory"
+ ignored: boolean
+}
+
+export interface Event {
+ type: string
+ properties: Record
+}
+
+export interface HealthResponse {
+ healthy: boolean
+ version: string
+}
+
+const REQUEST_TIMEOUT_MS = 30_000
+
+// Thrown by request() on a non-2xx response. Carries the HTTP status so
+// callers can distinguish e.g. 404 (older server, endpoint missing) from
+// other failures without parsing the message string.
+export class ApiError extends Error {
+ status: number
+ constructor(status: number, body: string) {
+ super(`API Error: ${status} - ${body}`)
+ this.name = "ApiError"
+ this.status = status
+ }
+}
+
+function createHeaders(config: ClientConfig): HeadersInit {
+ return buildRequestHeaders(config)
+}
+
+// `timeoutMs` lets specific callers (e.g. the onboarding health-check) fail
+// faster than the general REQUEST_TIMEOUT_MS used by real session calls.
+// Leave it unset to get the default.
+async function request(
+ config: ClientConfig,
+ path: string,
+ options: RequestInit = {},
+ timeoutMs?: number,
+): Promise {
+ const url = `${config.baseUrl}${path}`
+ const headers = { ...createHeaders(config), ...options.headers }
+ const response = await fetchWithTimeout(
+ url,
+ {
+ ...options,
+ headers,
+ },
+ timeoutMs,
+ )
+
+ if (!response.ok) {
+ const error = await response.text()
+ throw apiErrorFor(response.status, `API Error: ${response.status} - ${error}`)
+ }
+
+ return response.json()
+}
+
+async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise {
+ const parentSignal = options.signal
+ if (parentSignal?.aborted) throw new Error("Request aborted")
+
+ const controller = new AbortController()
+ let timedOut = false
+ const timeout = setTimeout(() => {
+ timedOut = true
+ controller.abort()
+ }, timeoutMs)
+ const onParentAbort = () => controller.abort()
+ parentSignal?.addEventListener("abort", onParentAbort)
+
+ try {
+ return await fetch(url, { ...options, signal: controller.signal })
+ } catch (error) {
+ if (timedOut) {
+ throw new Error(`Request timed out after ${timeoutMs}ms`)
+ }
+ throw error
+ } finally {
+ clearTimeout(timeout)
+ parentSignal?.removeEventListener("abort", onParentAbort)
+ }
+}
+
+export function createV1Client(config: ClientConfig) {
+ // Normalize once: a trailing slash on baseUrl (e.g. pasted into Advanced
+ // mode or the Edit screen) would otherwise survive into every
+ // `${config.baseUrl}${path}` concatenation below as a double slash, which
+ // every request then fails against (while the diagnostics probe, which
+ // reconstructs a clean URL, reports "works now"). A bare URL with no
+ // trailing slash is untouched.
+ config = { ...config, baseUrl: config.baseUrl.replace(/\/+$/, "") }
+ return {
+ global: {
+ // `timeoutMs` overrides the default REQUEST_TIMEOUT_MS — used by the
+ // onboarding connection test to fail fast on a bad/unreachable IP
+ // instead of hanging for the full 30s (issue: first-run bounce).
+ health: (timeoutMs?: number) => request(config, "/global/health", {}, timeoutMs),
+ // SSE event stream - returns async iterator
+ // Pass an AbortSignal to cancel the connection
+ async *events(signal?: AbortSignal): AsyncGenerator {
+ const url = `${config.baseUrl}/global/event`
+ const headers = createHeaders(config)
+ // Remove Content-Type for SSE (it's text/event-stream)
+ delete (headers as Record)["Content-Type"]
+
+ // Must use expo/fetch for ReadableStream support on native
+ const response = await expoFetch(url, { headers, signal })
+ if (!response.ok || !response.body) {
+ throw apiErrorFor(response.status, `Failed to connect to event stream: ${response.status}`)
+ }
+
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+ const parser = new SSEParser()
+
+ let receivedFirstByte = false
+ try {
+ while (true) {
+ const chunk = await readWithIdleTimeout(reader)
+ if (chunk === "idle") throw new Error("Event stream idle — no heartbeat received")
+ const { done, value } = chunk
+ if (done) {
+ console.log("[SSE] stream ended")
+ break
+ }
+
+ if (!receivedFirstByte) {
+ receivedFirstByte = true
+ console.log(`[SSE] first byte received (${value?.byteLength ?? 0} bytes)`)
+ }
+
+ const frames = parser.push(decoder.decode(value, { stream: true }))
+ if (frames.length === 0 && value?.byteLength) {
+ yield { type: "server.heartbeat", properties: {} }
+ }
+ for (const data of frames) {
+ try {
+ yield JSON.parse(data)
+ } catch (err) {
+ console.warn("[SSE] Failed to parse event", {
+ length: data.length,
+ error: err instanceof Error ? err.message : String(err),
+ })
+ }
+ }
+ }
+ } finally {
+ reader.releaseLock()
+ }
+ },
+ },
+
+ project: {
+ list: () => request(config, "/project"),
+ current: () => request(config, "/project/current"),
+ },
+
+ // Server-side filesystem browsing, scoped to this client's directory
+ // (see ClientConfig.directory / x-opencode-directory header). Use
+ // clientForDirectory(dir) to get a client rooted at a specific folder,
+ // then list("." ) to enumerate its immediate children.
+ file: {
+ list: (params: { path?: string } = {}) => {
+ const query = new URLSearchParams({ path: params.path ?? "." })
+ return request(config, `/file?${query.toString()}`)
+ },
+ // Enumerate the server's filesystem roots (mounted drives, home dir)
+ // to seed the directory browser's pinned top-level entries. Resolves
+ // to null on servers that don't yet expose GET /file/roots (older
+ // opencode builds) so callers fall back to manual path entry instead
+ // of crashing; other errors propagate like any other request.
+ roots: async (): Promise => {
+ try {
+ return await request(config, "/file/roots")
+ } catch (err) {
+ if (err instanceof ApiError && err.status === 404) return null
+ throw err
+ }
+ },
+ },
+
+ path: {
+ get: () =>
+ request<{ home: string; state: string; config: string; worktree: string; directory: string }>(config, "/path"),
+ },
+
+ session: {
+ // Prefer the GLOBAL experimental endpoint (all sessions across every
+ // directory) so the Recent Sessions list works without the user first
+ // picking a folder — a directory-less GET /session is directory-scoped
+ // and returns [] on servers whose active dir has no sessions. Shaping
+ // (roots filter, search, sort-by-updated, limit) happens client-side in
+ // loadSessionList; we fetch /experimental/session with no query params
+ // because the server applies `limit` before we can filter to roots.
+ // Falls back to the legacy /session path only on 404 (older servers).
+ list: (params?: { roots?: boolean; limit?: number; search?: string }): Promise =>
+ loadSessionList(
+ {
+ getExperimental: async (): Promise => {
+ const response = await fetchWithTimeout(`${config.baseUrl}/experimental/session`, {
+ headers: createHeaders(config),
+ })
+ // Older servers lack this route — signal fallback to legacy /session.
+ if (response.status === 404) return null
+ if (!response.ok) {
+ const body = await response.text()
+ throw apiErrorFor(response.status, `API Error: ${response.status} - ${body}`)
+ }
+ return response.json()
+ },
+ getLegacy: (query) => request(config, `/session${query}`),
+ },
+ params,
+ ),
+
+ get: (sessionID: string) => request(config, `/session/${sessionID}`),
+
+ // Older servers don't expose an authoritative status map. Callers fall
+ // back to the newest message's completed/error state after reconnect.
+ status: async () => null,
+
+ create: (params?: { title?: string }) =>
+ request(config, "/session", {
+ method: "POST",
+ body: JSON.stringify(params || {}),
+ }),
+
+ delete: (sessionID: string) => request(config, `/session/${sessionID}`, { method: "DELETE" }),
+
+ update: (sessionID: string, params: { title?: string; time?: { archived?: number } }) =>
+ request(config, `/session/${sessionID}`, {
+ method: "PATCH",
+ body: JSON.stringify(params),
+ }),
+
+ messages: (sessionID: string, params?: { limit?: number }) => {
+ const query = new URLSearchParams()
+ if (params?.limit) query.set("limit", String(params.limit))
+ const qs = query.toString()
+ return request(config, `/session/${sessionID}/message${qs ? `?${qs}` : ""}`)
+ },
+
+ // Sends a message and returns the response
+ // Fire-and-forget async prompt - SSE events drive all real-time updates
+ prompt: async (
+ sessionID: string,
+ params: {
+ parts: Array<{ type: "text"; text: string } | { type: "file"; mime: string; url: string; filename?: string }>
+ model?: { providerID: string; modelID: string }
+ agent?: string
+ variant?: string
+ },
+ ): Promise => {
+ const url = `${config.baseUrl}/session/${sessionID}/prompt_async`
+ const headers = createHeaders(config)
+ const body = JSON.stringify(legacyPromptBody(params))
+ const response = await fetchWithTimeout(url, {
+ method: "POST",
+ headers,
+ body,
+ })
+
+ if (!response.ok) {
+ const error = await response.text()
+ throw new Error(`Failed to send message: ${response.status} - ${error}`)
+ }
+ },
+
+ command: async (
+ sessionID: string,
+ params: {
+ command: string
+ arguments: string
+ agent?: string
+ model?: string
+ variant?: string
+ parts?: Array<{ type: "file"; mime: string; url: string; filename?: string }>
+ },
+ ): Promise => {
+ const url = `${config.baseUrl}/session/${sessionID}/command`
+ const headers = createHeaders(config)
+
+ const response = await fetchWithTimeout(url, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ ...params, sessionID }),
+ })
+
+ if (!response.ok) {
+ const error = await response.text()
+ throw new Error(`Failed to run command: ${response.status} - ${error}`)
+ }
+ },
+
+ abort: (sessionID: string) => request(config, `/session/${sessionID}/abort`, { method: "POST" }),
+
+ diff: (sessionID: string, messageID?: string) => {
+ const qs = messageID ? `?messageID=${messageID}` : ""
+ return request(config, `/session/${sessionID}/diff${qs}`)
+ },
+
+ // Marks messageID (and everything after it) as pending revert. The
+ // underlying messages aren't deleted until the next prompt runs
+ // cleanup, or the revert is undone with unrevert() below.
+ revert: (sessionID: string, messageID: string, partID?: string) =>
+ request(config, `/session/${sessionID}/revert`, {
+ method: "POST",
+ body: JSON.stringify(partID ? { messageID, partID } : { messageID }),
+ }),
+
+ unrevert: (sessionID: string) =>
+ request(config, `/session/${sessionID}/unrevert`, {
+ method: "POST",
+ }),
+ },
+
+ permission: {
+ list: () =>
+ request>(config, "/permission"),
+
+ reply: (requestID: string, reply: "once" | "always" | "reject") => {
+ const endpoint = permissionReplyRequest("v1", requestID, reply)
+ return request(config, endpoint.path, {
+ method: "POST",
+ body: JSON.stringify(endpoint.body),
+ })
+ },
+ },
+
+ question: {
+ list: () => request>(config, "/question"),
+
+ reply: (requestID: string, answers: string[][]) =>
+ request(config, `/question/${requestID}/reply`, {
+ method: "POST",
+ body: JSON.stringify({ answers }),
+ }),
+
+ reject: (requestID: string) =>
+ request(config, `/question/${requestID}/reject`, {
+ method: "POST",
+ }),
+ },
+
+ agent: {
+ list: () => request(config, "/agent"),
+ },
+
+ command: {
+ list: () => request(config, "/command"),
+ },
+
+ provider: {
+ list: () =>
+ request<{
+ all: Array<{
+ id: string
+ name: string
+ models: Record<
+ string,
+ {
+ id: string
+ name: string
+ attachment: boolean
+ reasoning: boolean
+ tool_call: boolean
+ cost?: { input: number; output: number }
+ limit: { context: number; output: number }
+ status?: "alpha" | "beta" | "deprecated" | "active"
+ variants?: Record
+ }
+ >
+ }>
+ default: Record
+ connected: string[]
+ }>(config, "/provider"),
+ },
+
+ config: {
+ get: () => request(config, "/config"),
+ },
+ }
+}
+
+export type V1Client = ReturnType
diff --git a/src/lib/sdk.ts b/src/lib/sdk.ts
index 89ffb68c..f7313d01 100644
--- a/src/lib/sdk.ts
+++ b/src/lib/sdk.ts
@@ -1,13 +1,53 @@
-// SDK client wrapper for React Native
+// SDK client wrapper for React Native — opencode v2 server API.
// We create our own lightweight client that mirrors the opencode SDK patterns
// but works in React Native environment
// expo/fetch provides WinterCG-compliant fetch with ReadableStream support for SSE
+//
+// v2 notes (see docs/v2-api-map.md): every endpoint lives under /api/, list
+// responses are {data, cursor} envelopes, SSE emits typed execution events,
+// and location scoping travels as a `location[directory]` query param (the
+// legacy x-opencode-directory header still works, so headers.ts is unchanged).
+// All v2 -> internal-shape conversion lives in v2-shape.ts so it stays
+// unit-testable outside RN.
import { fetch as expoFetch } from "expo/fetch"
import { buildRequestHeaders } from "./headers"
import { SSEParser } from "./sse"
-import { apiErrorFor } from "./api-error"
+import { apiErrorFor, isAuthStatus } from "./api-error"
import { loadSessionList } from "./session-list"
+import { v2PromptBody } from "./composer-prompt"
+import { readWithIdleTimeout } from "./sse-lifecycle"
+import { permissionReplyRequest } from "./permissions"
import type { FileRoot } from "./file-roots"
+import { createV1Client } from "./sdk-v1"
+import { apiForUnprobedServer, detectServerApi, type ProbeRequest, type ServerApi, type ServerIdentity } from "./api-detect"
+import {
+ formAnswerFromFields,
+ pendingRequestInfo,
+ rememberForm,
+ rememberPermissionRequest,
+ toAgent,
+ toCommand,
+ toFileEntries,
+ toMessageWithParts,
+ toPermissionRequest,
+ toProject,
+ toProviderCatalog,
+ toQuestionRequest,
+ toSession,
+ V2EventTranslator,
+ type V2AgentInfo,
+ type V2CommandInfo,
+ type V2FormInfo,
+ type V2FsEntry,
+ type V2LocationRef,
+ type V2Message,
+ type V2ModelInfo,
+ type V2PermissionRequest,
+ type V2ProjectInfo,
+ type V2ProviderInfo,
+ type V2RawEvent,
+ type V2SessionInfo,
+} from "./v2-shape"
export { ApiAuthError, isAuthError } from "./api-error"
@@ -18,6 +58,11 @@ export interface ClientConfig {
username: string
password: string
}
+ // Which server API this connection speaks, as recorded by probeServer().
+ // Omitted means "v1" until a probe succeeds; this lets saved 1.x servers
+ // remain reconnectable when the phone opens offline. New connections persist
+ // the probe result before use.
+ api?: ServerApi
}
export interface Session {
@@ -41,8 +86,8 @@ export interface Session {
files: number
}
// Present while a message (and everything after it) is pending revert —
- // the server keeps the underlying messages until the next prompt/summarize
- // call runs cleanup (or the revert is undone via session.unrevert).
+ // v2 keeps the staged boundary until the next prompt commits it or
+ // session.unrevert (DELETE revert) discards it.
revert?: {
messageID: string
partID?: string
@@ -175,9 +220,9 @@ export interface HealthResponse {
const REQUEST_TIMEOUT_MS = 30_000
-// Thrown by request() on a non-2xx response. Carries the HTTP status so
-// callers can distinguish e.g. 404 (older server, endpoint missing) from
-// other failures without parsing the message string.
+// Thrown by request() on a non-2xx, non-auth response. Carries the HTTP status
+// so callers can distinguish e.g. 404 (older server, endpoint missing) or 409
+// (session busy) from other failures without parsing the message string.
export class ApiError extends Error {
status: number
constructor(status: number, body: string) {
@@ -191,6 +236,17 @@ function createHeaders(config: ClientConfig): HeadersInit {
return buildRequestHeaders(config)
}
+// v2 location-scoped endpoints take the directory as a bracketed query param:
+// `location[directory]=/path/to/dir` (verified against a live v2 server).
+function locationQuery(directory?: string): string {
+ return directory ? `location%5Bdirectory%5D=${encodeURIComponent(directory)}` : ""
+}
+
+function appendQuery(path: string, query: string): string {
+ if (!query) return path
+ return `${path}${path.includes("?") ? "&" : "?"}${query}`
+}
+
// `timeoutMs` lets specific callers (e.g. the onboarding health-check) fail
// faster than the general REQUEST_TIMEOUT_MS used by real session calls.
// Leave it unset to get the default.
@@ -213,10 +269,15 @@ async function request(
if (!response.ok) {
const error = await response.text()
- throw apiErrorFor(response.status, `API Error: ${response.status} - ${error}`)
+ const message = `API Error: ${response.status} - ${error}`
+ throw isAuthStatus(response.status) ? apiErrorFor(response.status, message) : new ApiError(response.status, message)
}
- return response.json()
+ // Several v2 endpoints answer 204/empty (model/agent selection, form &
+ // permission replies) — parsing "" as JSON would throw.
+ const body = await response.text()
+ if (!body) return undefined as T
+ return JSON.parse(body)
}
async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise {
@@ -245,7 +306,7 @@ async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutM
}
}
-export function createClient(config: ClientConfig) {
+function createV2Client(config: ClientConfig) {
// Normalize once: a trailing slash on baseUrl (e.g. pasted into Advanced
// mode or the Edit screen) would otherwise survive into every
// `${config.baseUrl}${path}` concatenation below as a double slash, which
@@ -253,16 +314,25 @@ export function createClient(config: ClientConfig) {
// reconstructs a clean URL, reports "works now"). A bare URL with no
// trailing slash is untouched.
config = { ...config, baseUrl: config.baseUrl.replace(/\/+$/, "") }
+ const translator = new V2EventTranslator()
+ const loc = () => locationQuery(config.directory)
+
+ const getSession = async (sessionID: string): Promise => {
+ const body = await request<{ data: V2SessionInfo }>(config, `/api/session/${sessionID}`)
+ return toSession(body.data)
+ }
+
return {
global: {
- // `timeoutMs` overrides the default REQUEST_TIMEOUT_MS — used by the
- // onboarding connection test to fail fast on a bad/unreachable IP
- // instead of hanging for the full 30s (issue: first-run bounce).
- health: (timeoutMs?: number) => request(config, "/global/health", {}, timeoutMs),
+ // GET /api/info reports server identity/readiness — any 2xx means healthy.
+ health: async (timeoutMs?: number): Promise => {
+ const info = await request<{ version?: string }>(config, "/api/info", {}, timeoutMs)
+ return { healthy: true, version: info.version ?? "" }
+ },
// SSE event stream - returns async iterator
// Pass an AbortSignal to cancel the connection
async *events(signal?: AbortSignal): AsyncGenerator {
- const url = `${config.baseUrl}/global/event`
+ const url = `${config.baseUrl}/api/event`
const headers = createHeaders(config)
// Remove Content-Type for SSE (it's text/event-stream)
delete (headers as Record)["Content-Type"]
@@ -276,11 +346,17 @@ export function createClient(config: ClientConfig) {
const reader = response.body.getReader()
const decoder = new TextDecoder()
const parser = new SSEParser()
+ translator.reset()
let receivedFirstByte = false
try {
while (true) {
- const { done, value } = await reader.read()
+ const chunk = await readWithIdleTimeout(reader)
+ if (chunk === "idle") {
+ console.warn("[SSE] no data for 45s (server heartbeats every 15s) — dropping dead stream")
+ throw new Error("Event stream idle — no heartbeat received")
+ }
+ const { done, value } = chunk
if (done) {
console.log("[SSE] stream ended")
break
@@ -291,9 +367,19 @@ export function createClient(config: ClientConfig) {
console.log(`[SSE] first byte received (${value?.byteLength ?? 0} bytes)`)
}
- for (const data of parser.push(decoder.decode(value, { stream: true }))) {
+ const frames = parser.push(decoder.decode(value, { stream: true }))
+ // v2's liveness frame is an SSE comment, which the JSON parser
+ // correctly ignores. Surface raw chunk activity for the state
+ // machine without exposing transport details to screens.
+ if (frames.length === 0 && value?.byteLength) {
+ yield { type: "server.heartbeat", properties: {} } as Event
+ }
+ for (const data of frames) {
try {
- yield JSON.parse(data)
+ const raw = JSON.parse(data) as V2RawEvent
+ for (const translated of translator.translate(raw)) {
+ yield translated
+ }
} catch (err) {
console.warn("[SSE] Failed to parse event", {
length: data.length,
@@ -309,93 +395,174 @@ export function createClient(config: ClientConfig) {
},
project: {
- list: () => request(config, "/project"),
- current: () => request(config, "/project/current"),
+ // Bare array on v2 (no envelope).
+ list: async (): Promise => {
+ const body = await request(config, "/api/project")
+ return (body || []).map(toProject)
+ },
+ current: async (): Promise => {
+ const info = await request<{ directory: string; project?: { id: string; directory: string; canonical: string } }>(
+ config,
+ appendQuery("/api/location", loc()),
+ )
+ return {
+ id: info.project?.id ?? "",
+ name: info.project?.canonical ?? info.directory,
+ path: {
+ cwd: info.directory,
+ root: info.project?.canonical ?? info.directory,
+ absolute: info.directory,
+ },
+ }
+ },
},
// Server-side filesystem browsing, scoped to this client's directory
- // (see ClientConfig.directory / x-opencode-directory header). Use
+ // (see ClientConfig.directory / location query param). Use
// clientForDirectory(dir) to get a client rooted at a specific folder,
- // then list("." ) to enumerate its immediate children.
+ // then list(".") to enumerate its immediate children.
file: {
- list: (params: { path?: string } = {}) => {
+ list: async (params: { path?: string } = {}): Promise => {
const query = new URLSearchParams({ path: params.path ?? "." })
- return request(config, `/file?${query.toString()}`)
- },
- // Enumerate the server's filesystem roots (mounted drives, home dir)
- // to seed the directory browser's pinned top-level entries. Resolves
- // to null on servers that don't yet expose GET /file/roots (older
- // opencode builds) so callers fall back to manual path entry instead
- // of crashing; other errors propagate like any other request.
- roots: async (): Promise => {
- try {
- return await request(config, "/file/roots")
- } catch (err) {
- if (err instanceof ApiError && err.status === 404) return null
- throw err
- }
+ const body = await request<{ location?: V2LocationRef; data: V2FsEntry[] }>(
+ config,
+ `/api/fs/list?${query.toString()}${loc() ? `&${loc()}` : ""}`,
+ )
+ return toFileEntries(body.location, body.data || [])
},
+ // v2 has no filesystem-roots endpoint — resolve null so the directory
+ // browser falls back to manual path entry (same as pre-roots servers).
+ roots: async (): Promise => null,
},
path: {
- get: () =>
- request<{ home: string; state: string; config: string; worktree: string; directory: string }>(config, "/path"),
+ // v2 exposes the resolved location; there is no separate home/state
+ // config, so `home` falls back to the server's current directory (used
+ // by the directory browser only to expand `~`).
+ get: async () => {
+ const info = await request<{ directory: string; project?: { id: string; directory: string; canonical: string } }>(
+ config,
+ appendQuery("/api/location", loc()),
+ )
+ return {
+ home: info.directory,
+ state: "",
+ config: "",
+ worktree: info.project?.canonical ?? info.directory,
+ directory: info.directory,
+ }
+ },
},
session: {
- // Prefer the GLOBAL experimental endpoint (all sessions across every
- // directory) so the Recent Sessions list works without the user first
- // picking a folder — a directory-less GET /session is directory-scoped
- // and returns [] on servers whose active dir has no sessions. Shaping
- // (roots filter, search, sort-by-updated, limit) happens client-side in
- // loadSessionList; we fetch /experimental/session with no query params
- // because the server applies `limit` before we can filter to roots.
- // Falls back to the legacy /session path only on 404 (older servers).
+ // v2 GET /api/session without a directory filter returns sessions across
+ // every project (verified live), so it plays the role of the old global
+ // experimental endpoint. Shaping (roots filter, search, sort, limit)
+ // still happens client-side in loadSessionList — fetch a generous pool
+ // because the server applies `limit` before we can filter.
list: (params?: { roots?: boolean; limit?: number; search?: string }): Promise =>
loadSessionList(
{
getExperimental: async (): Promise => {
- const response = await fetchWithTimeout(`${config.baseUrl}/experimental/session`, {
+ const response = await fetchWithTimeout(`${config.baseUrl}/api/session?limit=500&order=desc`, {
headers: createHeaders(config),
})
- // Older servers lack this route — signal fallback to legacy /session.
+ // v1 servers lack /api/session — signal fallback to legacy /session.
if (response.status === 404) return null
if (!response.ok) {
const body = await response.text()
throw apiErrorFor(response.status, `API Error: ${response.status} - ${body}`)
}
- return response.json()
+ const body = (await response.json()) as { data?: V2SessionInfo[] }
+ return (body.data || []).map(toSession)
},
+ // Legacy fallback only runs against v1 servers, whose /session
+ // response is already in this app's Session shape — pass through.
getLegacy: (query) => request(config, `/session${query}`),
},
params,
),
- get: (sessionID: string) => request(config, `/session/${sessionID}`),
+ get: getSession,
- create: (params?: { title?: string }) =>
- request(config, "/session", {
+ status: async (): Promise | null> => {
+ try {
+ // Present on the latest stable v1.19.x HTTP API and current v2 API.
+ return await request(config, appendQuery("/api/session/status", loc()))
+ } catch (err) {
+ if (!(err instanceof ApiError) || err.status !== 404) throw err
+ // Early v2 builds exposed an active-only map instead. A missing
+ // session in that response is idle; keep this 404-only fallback.
+ try {
+ const body = await request<{ data?: Record }>(
+ config,
+ appendQuery("/api/session/active", loc()),
+ )
+ return Object.fromEntries(Object.entries(body.data || {}).map(([sessionID, value]) => [
+ sessionID,
+ value.type === "retry"
+ ? { type: "retry" as const, attempt: value.attempt, message: value.message }
+ : { type: "busy" as const },
+ ]))
+ } catch (fallbackError) {
+ if (fallbackError instanceof ApiError && fallbackError.status === 404) return null
+ throw fallbackError
+ }
+ }
+ },
+
+ create: async (params?: { title?: string }): Promise => {
+ const body = await request<{ data: V2SessionInfo }>(config, "/api/session", {
method: "POST",
body: JSON.stringify(params || {}),
- }),
+ })
+ return toSession(body.data)
+ },
- delete: (sessionID: string) => request(config, `/session/${sessionID}`, { method: "DELETE" }),
+ delete: (sessionID: string) => request(config, `/api/session/${sessionID}`, { method: "DELETE" }),
- update: (sessionID: string, params: { title?: string; time?: { archived?: number } }) =>
- request(config, `/session/${sessionID}`, {
+ // PATCH only answers 204 on v2 — re-fetch so callers get the updated
+ // Session like v1 did.
+ update: async (sessionID: string, params: { title?: string; time?: { archived?: number } }): Promise => {
+ await request(config, `/api/session/${sessionID}`, {
method: "PATCH",
- body: JSON.stringify(params),
- }),
-
- messages: (sessionID: string, params?: { limit?: number }) => {
- const query = new URLSearchParams()
- if (params?.limit) query.set("limit", String(params.limit))
- const qs = query.toString()
- return request(config, `/session/${sessionID}/message${qs ? `?${qs}` : ""}`)
+ body: JSON.stringify({ title: params.title }),
+ })
+ return getSession(sessionID)
+ },
+
+ // v2 pages newest-first with order=desc; the app's store is oldest-first,
+ // so every page is reversed. With no limit, follow cursor.next until the
+ // timeline is exhausted (loadOlderMessages wants ALL messages).
+ messages: async (sessionID: string, params?: { limit?: number }): Promise => {
+ if (params?.limit) {
+ const body = await request<{ data: V2Message[] }>(
+ config,
+ `/api/session/${sessionID}/message?order=desc&limit=${params.limit}`,
+ )
+ return toMessageWithParts(body.data || []).reverse()
+ }
+ const all: V2Message[] = []
+ let cursor: string | null = null
+ for (let page = 0; page < 50; page++) {
+ // v2 rejects cursor+order together — the cursor already encodes direction.
+ const qs: string = cursor ? `cursor=${encodeURIComponent(cursor)}` : "order=desc"
+ const body: { data: V2Message[]; cursor?: { next?: string | null } } = await request<{ data: V2Message[]; cursor?: { next?: string | null } }>(
+ config,
+ `/api/session/${sessionID}/message?${qs}`,
+ )
+ all.push(...(body.data || []))
+ cursor = body.cursor?.next ?? null
+ if (!cursor) break
+ }
+ return toMessageWithParts(all).reverse()
},
// Sends a message and returns the response
- // Fire-and-forget async prompt - SSE events drive all real-time updates
+ // Fire-and-forget async prompt - SSE events drive all real-time updates.
+ // v2 model/agent selection is session state, applied before the prompt;
+ // a staged revert (from "edit message") is committed automatically by the
+ // next prompt (verified live), so no explicit commit call is needed.
prompt: async (
sessionID: string,
params: {
@@ -405,19 +572,29 @@ export function createClient(config: ClientConfig) {
variant?: string
},
): Promise => {
- const url = `${config.baseUrl}/session/${sessionID}/prompt_async`
- const headers = createHeaders(config)
- const body = JSON.stringify(params)
- const response = await fetchWithTimeout(url, {
+ if (params.model) {
+ await request(config, `/api/session/${sessionID}/model`, {
+ method: "POST",
+ body: JSON.stringify({
+ model: {
+ id: params.model.modelID,
+ providerID: params.model.providerID,
+ ...(params.variant ? { variant: params.variant } : {}),
+ },
+ }),
+ })
+ }
+ if (params.agent) {
+ await request(config, `/api/session/${sessionID}/agent`, {
+ method: "POST",
+ body: JSON.stringify({ agent: params.agent }),
+ })
+ }
+ const prompt = v2PromptBody(params.parts)
+ await request(config, `/api/session/${sessionID}/prompt`, {
method: "POST",
- headers,
- body,
+ body: JSON.stringify(prompt),
})
-
- if (!response.ok) {
- const error = await response.text()
- throw new Error(`Failed to send message: ${response.status} - ${error}`)
- }
},
command: async (
@@ -431,107 +608,178 @@ export function createClient(config: ClientConfig) {
parts?: Array<{ type: "file"; mime: string; url: string; filename?: string }>
},
): Promise => {
- const url = `${config.baseUrl}/session/${sessionID}/command`
- const headers = createHeaders(config)
-
- const response = await fetchWithTimeout(url, {
+ await request(config, `/api/session/${sessionID}/command`, {
method: "POST",
- headers,
- body: JSON.stringify({ ...params, sessionID }),
+ body: JSON.stringify({
+ name: params.command,
+ text: params.arguments,
+ files: (params.parts || []).map((p) => ({ uri: p.url, ...(p.filename ? { name: p.filename } : {}) })),
+ }),
})
-
- if (!response.ok) {
- const error = await response.text()
- throw new Error(`Failed to run command: ${response.status} - ${error}`)
- }
},
- abort: (sessionID: string) => request(config, `/session/${sessionID}/abort`, { method: "POST" }),
+ // v2 replaces abort with interrupt (POST returns {interrupted}).
+ abort: async (sessionID: string): Promise => {
+ const body = await request<{ interrupted?: boolean }>(config, `/api/session/${sessionID}/interrupt`, {
+ method: "POST",
+ })
+ return Boolean(body.interrupted)
+ },
- diff: (sessionID: string, messageID?: string) => {
- const qs = messageID ? `?messageID=${messageID}` : ""
- return request(config, `/session/${sessionID}/diff${qs}`)
+ // v2 diff is turn-based (from/to), no messageID scoping needed —
+ // callers get the session's structured per-file diffs.
+ diff: async (sessionID: string): Promise => {
+ const body = await request<{ data: unknown[] }>(config, `/api/session/${sessionID}/diff`)
+ return body.data || []
},
- // Marks messageID (and everything after it) as pending revert. The
- // underlying messages aren't deleted until the next prompt runs
- // cleanup, or the revert is undone with unrevert() below.
- revert: (sessionID: string, messageID: string, partID?: string) =>
- request(config, `/session/${sessionID}/revert`, {
+ // Stages a reversible session boundary at messageID. The underlying
+ // messages aren't deleted until the next prompt commits the revert, or
+ // unrevert() below discards it. The stage endpoint answers with the
+ // boundary itself, so re-fetch the session for its updated `revert`.
+ revert: async (sessionID: string, messageID: string): Promise => {
+ await request(config, `/api/session/${sessionID}/revert/stage`, {
method: "POST",
- body: JSON.stringify(partID ? { messageID, partID } : { messageID }),
- }),
+ body: JSON.stringify({ messageID }),
+ })
+ return getSession(sessionID)
+ },
- unrevert: (sessionID: string) =>
- request(config, `/session/${sessionID}/unrevert`, {
- method: "POST",
- }),
+ unrevert: async (sessionID: string): Promise => {
+ await request(config, `/api/session/${sessionID}/revert`, { method: "DELETE" })
+ return getSession(sessionID)
+ },
},
permission: {
- list: () =>
- request>(config, "/permission"),
+ // v2 permission requests are session-scoped; refreshPending always knows
+ // the session it's refreshing, so prefer the session endpoint and only
+ // hit the location-global one as a fallback.
+ list: async (sessionID?: string) => {
+ const path = sessionID ? `/api/session/${sessionID}/permission` : appendQuery("/api/permission/request", loc())
+ const body = await request<{ data: V2PermissionRequest[] }>(config, path)
+ const out = (body.data || []).map((p) => {
+ rememberPermissionRequest(p)
+ return toPermissionRequest(p)
+ })
+ return out
+ },
- reply: (requestID: string, reply: "once" | "always" | "reject") =>
- request(config, `/permission/${requestID}/reply`, {
+ reply: async (requestID: string, reply: "once" | "always" | "reject"): Promise => {
+ const known = pendingRequestInfo(requestID)
+ if (!known) throw new Error(`Unknown permission request: ${requestID}`)
+ const endpoint = permissionReplyRequest("v2", requestID, reply, known.sessionID)
+ await request(config, endpoint.path, {
method: "POST",
- body: JSON.stringify({ reply }),
- }),
+ body: JSON.stringify(endpoint.body),
+ })
+ return true
+ },
},
question: {
- list: () => request>(config, "/question"),
+ // v2 calls these "forms"; they map 1:1 onto the app's question prompts.
+ list: async (sessionID?: string) => {
+ const path = sessionID ? `/api/session/${sessionID}/form` : appendQuery("/api/form", loc())
+ const body = await request<{ data: V2FormInfo[] }>(config, path)
+ const out = (body.data || []).map((f) => {
+ rememberForm(f)
+ return toQuestionRequest(f)
+ })
+ return out
+ },
- reply: (requestID: string, answers: string[][]) =>
- request(config, `/question/${requestID}/reply`, {
+ reply: async (requestID: string, answers: string[][]): Promise => {
+ const known = pendingRequestInfo(requestID)
+ if (!known) throw new Error(`Unknown question request: ${requestID}`)
+ await request(config, `/api/session/${known.sessionID}/form/${requestID}/reply`, {
method: "POST",
- body: JSON.stringify({ answers }),
- }),
+ body: JSON.stringify({ answer: formAnswerFromFields(known.fields, answers) }),
+ })
+ return true
+ },
- reject: (requestID: string) =>
- request(config, `/question/${requestID}/reject`, {
- method: "POST",
- }),
+ reject: async (requestID: string): Promise => {
+ const known = pendingRequestInfo(requestID)
+ if (!known) throw new Error(`Unknown question request: ${requestID}`)
+ await request(config, `/api/session/${known.sessionID}/form/${requestID}`, { method: "DELETE" })
+ return true
+ },
},
agent: {
- list: () => request(config, "/agent"),
+ list: async (): Promise => {
+ const body = await request<{ data: V2AgentInfo[] }>(config, appendQuery("/api/agent", loc()))
+ return (body.data || []).map(toAgent)
+ },
},
command: {
- list: () => request(config, "/command"),
+ list: async (): Promise => {
+ const body = await request<{ data: V2CommandInfo[] }>(config, appendQuery("/api/command", loc()))
+ return (body.data || []).map(toCommand)
+ },
},
provider: {
- list: () =>
- request<{
- all: Array<{
- id: string
- name: string
- models: Record<
- string,
- {
- id: string
- name: string
- attachment: boolean
- reasoning: boolean
- tool_call: boolean
- cost?: { input: number; output: number }
- limit: { context: number; output: number }
- status?: "alpha" | "beta" | "deprecated" | "active"
- variants?: Record
- }
- >
- }>
- default: Record
- connected: string[]
- }>(config, "/provider"),
+ list: async () => {
+ // Models are location-scoped; without a configured directory, resolve
+ // the server's active location so the model catalog is never empty.
+ let location = loc()
+ if (!location) {
+ try {
+ const here = await request<{ directory: string }>(config, "/api/location")
+ location = locationQuery(here.directory)
+ } catch {
+ // leave unscoped — provider list alone still renders something
+ }
+ }
+ const [providers, models, defaultModel] = await Promise.all([
+ request<{ data: V2ProviderInfo[] }>(config, appendQuery("/api/provider", location)),
+ request<{ data: V2ModelInfo[] }>(config, appendQuery("/api/model", location)),
+ request<{ data: V2ModelInfo | null }>(config, appendQuery("/api/model/default", location)),
+ ])
+ return toProviderCatalog(providers.data || [], models.data || [], defaultModel.data ?? null)
+ },
},
config: {
- get: () => request(config, "/config"),
+ get: () => request(config, "/api/config"),
},
}
}
-export type Client = ReturnType
+export type Client = ReturnType
+
+// --- v1 / v2 transport dispatch ---------------------------------------------
+// Both APIs are in active use. The v1 adapter preserves existing server
+// compatibility, while probeServer() records when a server speaks the /api
+// transport. Unprobed saved connections default to v1 so a phone opened offline
+// does not accidentally switch an older server to an incompatible API.
+export function createClient(config: ClientConfig): Client {
+ if (apiForUnprobedServer(config.api) === "v1") {
+ // Both clients implement the same runtime contract. The cast covers the one
+ // place their types differ nominally: v1's /question and /permission lists
+ // hand back untyped records, while the v2 mapper produces shaped requests —
+ // and events.ts already treats both as records in refreshPending().
+ return createV1Client(config) as unknown as Client
+ }
+ return createV2Client(config)
+}
+
+// A probe is a diagnostic, not a session call: it should answer in about a
+// second on a LAN and give up before the onboarding flow's own timeout.
+const PROBE_TIMEOUT_MS = 8_000
+
+/** Probe a connection and report which server API it speaks (see api-detect.ts). */
+export async function probeServer(config: ClientConfig): Promise {
+ const normalized: ClientConfig = { ...config, baseUrl: config.baseUrl.replace(/\/+$/, "") }
+ const headers = createHeaders(normalized)
+ const probe: ProbeRequest = async (path) => {
+ const response = await fetchWithTimeout(`${normalized.baseUrl}${path}`, { headers }, PROBE_TIMEOUT_MS)
+ const contentType = response.headers.get("content-type") ?? ""
+ const body = await response.text().catch(() => "")
+ return { status: response.status, contentType, body }
+ }
+ return detectServerApi(probe)
+}
diff --git a/src/lib/sentry-noise.ts b/src/lib/sentry-noise.ts
index cccdc227..79e4953d 100644
--- a/src/lib/sentry-noise.ts
+++ b/src/lib/sentry-noise.ts
@@ -54,9 +54,11 @@ export type NoiseEventLike = {
export const TRANSPORT_NOISE_PATTERNS: RegExp[] = [
// captureDiagnostic() → new Error(`connect ${classification}`)
// `auth-failed` = the server answered 401/403: wrong password, user config.
+ // `auth-required` = 401/403 with no credentials sent (empty password field):
+ // same family — user config, not a defect.
// `health-failed` and `tls-error` are deliberately NOT here — a box that
// answers but is unhealthy, or a broken cert, is actionable.
- /^connect (?:timeout|server-unreachable|no-internet|malformed-url|auth-failed)$/i,
+ /^connect (?:timeout|server-unreachable|no-internet|malformed-url|auth-failed|auth-required)$/i,
// RN fetch failures surfacing through the global handler / rejection hook.
/network request failed/i,
/request timed out after/i,
diff --git a/src/lib/session-drafts.test.ts b/src/lib/session-drafts.test.ts
new file mode 100644
index 00000000..2d62af66
--- /dev/null
+++ b/src/lib/session-drafts.test.ts
@@ -0,0 +1,31 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+import { readSessionDraft, writeSessionDraft, type DraftStorage } from "./session-drafts.ts"
+
+function memoryStorage(): DraftStorage {
+ const values = new Map()
+ return {
+ async getItemAsync(key) { return values.get(key) ?? null },
+ async setItemAsync(key, value) { values.set(key, value) },
+ async deleteItemAsync(key) { values.delete(key) },
+ }
+}
+
+test("encrypted draft storage preserves long Unicode, Markdown, and line endings exactly", async () => {
+ const storage = memoryStorage()
+ const phrase = "Follow the spec 🙂\r\n```json\n{\"greeting\":\"héllo\"}\n```\ne\u0301 — "
+ const text = phrase.repeat(5_000)
+ await writeSessionDraft(storage, "ses_1", text)
+ const restored = await readSessionDraft(storage, "ses_1")
+ assert.equal(restored, text)
+ assert.equal(Buffer.byteLength(restored ?? "", "utf8"), Buffer.byteLength(text, "utf8"))
+})
+
+test("drafts are isolated by session and empty text removes only that draft", async () => {
+ const storage = memoryStorage()
+ await writeSessionDraft(storage, "ses_a", "draft a")
+ await writeSessionDraft(storage, "ses_b", "draft b")
+ await writeSessionDraft(storage, "ses_a", "")
+ assert.equal(await readSessionDraft(storage, "ses_a"), null)
+ assert.equal(await readSessionDraft(storage, "ses_b"), "draft b")
+})
diff --git a/src/lib/session-drafts.ts b/src/lib/session-drafts.ts
new file mode 100644
index 00000000..2f98168c
--- /dev/null
+++ b/src/lib/session-drafts.ts
@@ -0,0 +1,20 @@
+export const SESSION_DRAFT_PREFIX = "opencode.session.draft.v1."
+
+export interface DraftStorage {
+ getItemAsync(key: string): Promise
+ setItemAsync(key: string, value: string): Promise
+ deleteItemAsync(key: string): Promise
+}
+
+function keyFor(sessionID: string): string {
+ return `${SESSION_DRAFT_PREFIX}${sessionID}`
+}
+
+export function readSessionDraft(storage: DraftStorage, sessionID: string): Promise {
+ return storage.getItemAsync(keyFor(sessionID))
+}
+
+export function writeSessionDraft(storage: DraftStorage, sessionID: string, text: string): Promise {
+ if (text.length === 0) return storage.deleteItemAsync(keyFor(sessionID))
+ return storage.setItemAsync(keyFor(sessionID), text)
+}
diff --git a/src/lib/session-layout.test.ts b/src/lib/session-layout.test.ts
new file mode 100644
index 00000000..022c74ef
--- /dev/null
+++ b/src/lib/session-layout.test.ts
@@ -0,0 +1,31 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+import {
+ COMPOSER_MAX_HEIGHT,
+ COMPOSER_MIN_HEIGHT,
+ composerMaxHeight,
+ IOS_KEYBOARD_VERTICAL_OFFSET,
+ keyboardVerticalOffset,
+ QUESTION_BODY_SCROLL_PROPS,
+} from "./session-layout.ts"
+
+test("keyboard offset uses the top inset only on Android", () => {
+ assert.equal(keyboardVerticalOffset("ios", 10), IOS_KEYBOARD_VERTICAL_OFFSET)
+ assert.equal(keyboardVerticalOffset("android", 80), 80)
+ assert.equal(keyboardVerticalOffset("android", -1), 0)
+})
+
+test("composer height stays bounded while keeping two scaled lines usable", () => {
+ assert.equal(composerMaxHeight("ios", 900, 400, 40), COMPOSER_MAX_HEIGHT)
+ assert.equal(composerMaxHeight("android", 900, null, 40), COMPOSER_MAX_HEIGHT)
+ assert.equal(composerMaxHeight("android", 900, 500, 40, 1), COMPOSER_MAX_HEIGHT)
+ assert.equal(composerMaxHeight("android", 900, 280, 40, 1), COMPOSER_MIN_HEIGHT)
+})
+
+test("interactive form body keeps taps and nested scrolling available", () => {
+ 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..bafa9a3e
--- /dev/null
+++ b/src/lib/session-layout.ts
@@ -0,0 +1,32 @@
+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
+
+// Android reports IME geometry in screen coordinates while
+// KeyboardAvoidingView measures the edge-to-edge frame in window coordinates.
+// The top safe-area inset reconciles those coordinate spaces.
+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
+ 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
diff --git a/src/lib/sse-lifecycle.test.ts b/src/lib/sse-lifecycle.test.ts
new file mode 100644
index 00000000..ccc10a15
--- /dev/null
+++ b/src/lib/sse-lifecycle.test.ts
@@ -0,0 +1,39 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+import { readWithIdleTimeout, reconnectDelayMs, sessionActivityFromMessages, shouldReconnectOnResume, shouldResetRetries } from "./sse-lifecycle.ts"
+
+test("a half-open SSE read times out and cancels its reader", async () => {
+ let cancelled = false
+ const result = await readWithIdleTimeout({
+ read: () => new Promise(() => {}),
+ cancel: async () => { cancelled = true },
+ }, 5)
+ assert.equal(result, "idle")
+ assert.equal(cancelled, true)
+})
+
+test("retry backoff is bounded and jittered", () => {
+ assert.equal(reconnectDelayMs(1, () => 0), 750)
+ assert.equal(reconnectDelayMs(2, () => 0.5), 2_000)
+ assert.equal(reconnectDelayMs(99, () => 1), 15_000)
+})
+
+test("retry counter resets only after actual stream data", () => {
+ assert.equal(shouldResetRetries(false), false)
+ assert.equal(shouldResetRetries(true), true)
+})
+
+test("foreground reconnect is skipped for a live stream and in-flight attempt", () => {
+ assert.equal(shouldReconnectOnResume({ transport: "live", attemptInFlight: false }), false)
+ assert.equal(shouldReconnectOnResume({ transport: "connecting", attemptInFlight: true }), false)
+ assert.equal(shouldReconnectOnResume({ transport: "backgrounded", attemptInFlight: false }), true)
+ assert.equal(shouldReconnectOnResume({ transport: "reconnecting", attemptInFlight: false }), true)
+})
+
+test("session activity reconciliation distinguishes empty, active, and completed timelines", () => {
+ assert.equal(sessionActivityFromMessages([]), "unknown")
+ assert.equal(sessionActivityFromMessages([{ role: "user" }]), "busy")
+ assert.equal(sessionActivityFromMessages([{ role: "assistant" }]), "busy")
+ assert.equal(sessionActivityFromMessages([{ role: "assistant", time: { completed: 123 } }]), "idle")
+ assert.equal(sessionActivityFromMessages([{ role: "assistant", error: { message: "failed" } }]), "idle")
+})
diff --git a/src/lib/sse-lifecycle.ts b/src/lib/sse-lifecycle.ts
new file mode 100644
index 00000000..e4fda4f5
--- /dev/null
+++ b/src/lib/sse-lifecycle.ts
@@ -0,0 +1,57 @@
+// The v2 API sends heartbeat comments every ~15s; v1 servers send a JSON
+// heartbeat more frequently. Three missed v2 heartbeats bounds a half-open
+// connection without churning on a short radio stall.
+export const SSE_IDLE_TIMEOUT_MS = 45_000
+
+export interface SseReader {
+ read(): Promise<{ done?: boolean; value?: Uint8Array }>
+ cancel?(): Promise
+}
+
+export async function readWithIdleTimeout(
+ reader: SseReader,
+ timeoutMs = SSE_IDLE_TIMEOUT_MS,
+): Promise<{ done?: boolean; value?: Uint8Array } | "idle"> {
+ let timer: ReturnType | undefined
+ const read = reader.read().catch(() => ({ done: true, value: undefined }))
+ try {
+ const result = await Promise.race([
+ read,
+ new Promise<"idle">((resolve) => { timer = setTimeout(() => resolve("idle"), timeoutMs) }),
+ ])
+ if (result === "idle") void reader.cancel?.().catch(() => {})
+ return result
+ } finally {
+ if (timer) clearTimeout(timer)
+ }
+}
+
+export const RECONNECT_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 15_000] as const
+
+export type TransportState = "idle" | "backgrounded" | "connecting" | "live" | "reconnecting" | "auth-required"
+
+export function reconnectDelayMs(attempt: number, random: () => number = Math.random): number {
+ const index = Math.min(Math.max(attempt, 1) - 1, RECONNECT_DELAYS_MS.length - 1)
+ return Math.min(15_000, Math.round(RECONNECT_DELAYS_MS[index] * (0.75 + random() * 0.5)))
+}
+
+export function shouldResetRetries(receivedEvent: boolean): boolean {
+ return receivedEvent
+}
+
+/** Only the active, verified transport should suppress a foreground reconnect. */
+export function shouldReconnectOnResume(input: { transport: TransportState; attemptInFlight: boolean }): boolean {
+ if (input.transport === "live") return false
+ return !input.attemptInFlight
+}
+
+export function sessionActivityFromMessages(messages: Array<{
+ role: "user" | "assistant"
+ time?: { completed?: number }
+ error?: unknown
+}>): "unknown" | "busy" | "idle" {
+ if (messages.length === 0) return "unknown"
+ const last = messages[messages.length - 1]
+ if (last.role === "assistant" && (last.time?.completed || last.error)) return "idle"
+ return "busy"
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index d61a45c6..060c8628 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -1,3 +1,5 @@
+import type { ServerApi } from "./api-detect"
+
// Connection types for multiple server support
export type ConnectionType = "local" | "tunnel" | "cloud"
@@ -15,6 +17,12 @@ export interface ServerConnection {
lastConnected?: number
// Is this the active connection?
active?: boolean
+ // Which server API the last probe found. "v1" means the server predates the
+ // /api/ rewrite (opencode <= 1.18.x) and gets the legacy transport from
+ // sdk-v1.ts. Absent means "not probed yet", which behaves as v2.
+ api?: ServerApi
+ // Server version reported by that probe, shown in the connection UI.
+ serverVersion?: string
}
export interface AppSettings {
diff --git a/src/lib/v2-shape.test.ts b/src/lib/v2-shape.test.ts
new file mode 100644
index 00000000..0fb8e62f
--- /dev/null
+++ b/src/lib/v2-shape.test.ts
@@ -0,0 +1,377 @@
+import { test } from "node:test"
+import assert from "node:assert/strict"
+import {
+ formAnswerFromFields,
+ pendingRequestInfo,
+ rememberForm,
+ toMessageWithParts,
+ toProviderCatalog,
+ toQuestionRequest,
+ toSession,
+ V2EventTranslator,
+ type V2FormField,
+ type V2Message,
+ type V2ModelInfo,
+ type V2RawEvent,
+ type V2SessionInfo,
+} from "./v2-shape.ts"
+
+// Fixtures mirror the live v2 server capture (docs/v2-api-map.md): a prompt
+// runs as execution -> steps -> text/reasoning/tool events -> idle.
+
+const ev = (type: string, data: Record): V2RawEvent => ({
+ id: `evt-${type}`,
+ created: 1000,
+ type,
+ data,
+})
+
+test("toSession maps v2 location/time/revert into the app shape", () => {
+ const info: V2SessionInfo = {
+ id: "ses_1",
+ projectID: "proj1",
+ title: "Hello",
+ location: { directory: "/srv/proj" },
+ time: { created: 10, updated: 20 },
+ revert: { messageID: "msg_9" },
+ parentID: "ses_0",
+ }
+ assert.deepEqual(toSession(info), {
+ id: "ses_1",
+ slug: "",
+ projectID: "proj1",
+ directory: "/srv/proj",
+ parentID: "ses_0",
+ title: "Hello",
+ version: "",
+ time: { created: 10, updated: 20, archived: undefined },
+ revert: { messageID: "msg_9", partID: undefined },
+ })
+})
+
+test("toMessageWithParts reshapes user/assistant and drops timeline markers", () => {
+ const messages: V2Message[] = [
+ {
+ id: "msg_u1",
+ sessionID: "ses_1",
+ type: "user",
+ time: { created: 1 },
+ text: "hi",
+ files: [{ data: "YWJj", mime: "image/png", name: "a.png" }],
+ },
+ {
+ id: "msg_i1",
+ type: "idle",
+ time: { created: 2 },
+ outcome: "succeeded",
+ },
+ {
+ id: "msg_a1",
+ sessionID: "ses_1",
+ type: "assistant",
+ time: { created: 3, completed: 4 },
+ agent: "build",
+ model: { id: "m1", providerID: "p1" },
+ finish: "stop",
+ cost: 0.5,
+ tokens: { input: 1, output: 2, reasoning: 0, cache: { read: 0, write: 0 } },
+ content: [
+ { type: "reasoning", text: "thinking..." },
+ {
+ type: "tool",
+ id: "call1",
+ name: "write",
+ state: {
+ status: "completed",
+ input: { path: "/x" },
+ content: [{ type: "text", text: "done" }],
+ metadata: { title: "/x" },
+ },
+ time: { created: 5, completed: 6 },
+ },
+ { type: "text", text: "final answer" },
+ ],
+ },
+ ]
+ const out = toMessageWithParts(messages)
+ assert.equal(out.length, 2, "idle marker dropped")
+
+ const user = out[0]
+ assert.equal(user.info.role, "user")
+ assert.deepEqual(user.parts.map((p) => p.type), ["text", "file"])
+ assert.equal(user.parts[1].url, "data:image/png;base64,YWJj")
+
+ const assistant = out[1]
+ assert.equal(assistant.info.role, "assistant")
+ assert.equal(assistant.info.modelID, "m1")
+ assert.equal(assistant.info.providerID, "p1")
+ assert.equal(assistant.info.finish, "stop")
+ assert.deepEqual(assistant.parts.map((p) => p.type), ["reasoning", "tool", "text"])
+ const tool = assistant.parts[1]
+ assert.equal(tool.tool, "write")
+ assert.equal(tool.state?.status, "completed")
+ assert.equal(tool.state?.output, "done")
+ assert.equal(tool.state?.title, "/x")
+})
+
+test("error tool state surfaces a message the ToolCallCard can render", () => {
+ const out = toMessageWithParts([
+ {
+ id: "msg_a1",
+ sessionID: "s",
+ type: "assistant",
+ time: { created: 1 },
+ content: [
+ {
+ type: "tool",
+ id: "c1",
+ name: "bash",
+ state: { status: "error", input: { command: "false" }, error: { data: { message: "exit 1" } } },
+ },
+ ],
+ },
+ ])
+ const state = out[0].parts[0].state
+ assert.equal(state?.status, "error")
+ assert.equal(state?.error?.message, "exit 1")
+})
+
+test("translator replays a live execution flow into v1-style events", () => {
+ const t = new V2EventTranslator()
+ const stream: V2RawEvent[] = [
+ { type: "server.connected", data: {} },
+ ev("session.execution.started", { sessionID: "ses_1" }),
+ ev("session.step.started", {
+ sessionID: "ses_1",
+ agent: "build",
+ model: { id: "m1", providerID: "p1" },
+ assistantMessageID: "msg_a1",
+ started: 100,
+ }),
+ ev("session.reasoning.started", { sessionID: "ses_1", assistantMessageID: "msg_a1", ordinal: 0 }),
+ ev("session.reasoning.delta", { sessionID: "ses_1", assistantMessageID: "msg_a1", ordinal: 0, delta: "thi" }),
+ ev("session.reasoning.ended", { sessionID: "ses_1", assistantMessageID: "msg_a1", ordinal: 0, text: "thinking" }),
+ ev("session.tool.input.started", { sessionID: "ses_1", assistantMessageID: "msg_a1", id: "c1", name: "write" }),
+ ev("session.tool.input.delta", { sessionID: "ses_1", assistantMessageID: "msg_a1", id: "c1", delta: '{"path":' }),
+ ev("session.tool.input.ended", { sessionID: "ses_1", assistantMessageID: "msg_a1", id: "c1", text: '{"path":"/x"}' }),
+ ev("session.tool.called", { sessionID: "ses_1", assistantMessageID: "msg_a1", id: "c1", input: { path: "/x" } }),
+ ev("session.tool.success", {
+ sessionID: "ses_1",
+ assistantMessageID: "msg_a1",
+ id: "c1",
+ content: [{ type: "text", text: "Created /x" }],
+ metadata: { title: "/x" },
+ }),
+ ev("session.step.ended", { sessionID: "ses_1", assistantMessageID: "msg_a1", finish: "tool-calls", cost: 0, tokens: { input: 1, output: 2 } }),
+ ev("session.text.started", { sessionID: "ses_1", assistantMessageID: "msg_a2", ordinal: 0 }),
+ ev("session.text.delta", { sessionID: "ses_1", assistantMessageID: "msg_a2", ordinal: 0, delta: "3+3=" }),
+ ev("session.text.delta", { sessionID: "ses_1", assistantMessageID: "msg_a2", ordinal: 0, delta: "6" }),
+ ev("session.text.ended", { sessionID: "ses_1", assistantMessageID: "msg_a2", ordinal: 0, text: "3+3=6" }),
+ ev("session.usage.updated", { sessionID: "ses_1", cost: 1, tokens: { input: 9, output: 4 } }),
+ ev("session.execution.succeeded", { sessionID: "ses_1" }),
+ ]
+
+ const out = stream.flatMap((e) => t.translate(e))
+ const types = out.map((e) => e.type)
+
+ assert.equal(types[0], "session.status")
+ assert.deepEqual((out[0].properties.status as { type: string }).type, "busy")
+
+ // Assistant messages synthesized per step
+ const messageUpdates = out.filter((e) => e.type === "message.updated")
+ assert.deepEqual(
+ messageUpdates.map((e) => (e.properties.info as { id: string }).id),
+ ["msg_a1", "msg_a1", "msg_a2", "msg_a2"],
+ )
+
+ // Reasoning accumulates and the ended event carries the authoritative text
+ const reasoning = out
+ .filter((e) => e.type === "message.part.updated")
+ .map((e) => e.properties.part as { type: string; text?: string })
+ .filter((p) => p.type === "reasoning")
+ assert.deepEqual(reasoning.map((p) => p.text), ["", "thi", "thinking"])
+
+ // Tool part: running while streaming input, completed with parsed input + output
+ const toolParts = out
+ .filter((e) => e.type === "message.part.updated")
+ .map((e) => e.properties.part as { type: string; state?: { status: string; input?: unknown; output?: string; title?: string } })
+ .filter((p) => p.type === "tool")
+ assert.equal(toolParts[0].state?.status, "running")
+ assert.equal(toolParts[toolParts.length - 1].state?.status, "completed")
+ assert.deepEqual(toolParts[toolParts.length - 1].state?.input, { path: "/x" })
+ assert.equal(toolParts[toolParts.length - 1].state?.output, "Created /x")
+ assert.equal(toolParts[toolParts.length - 1].state?.title, "/x")
+
+ // Text deltas accumulate into the full text
+ const textParts = out
+ .filter((e) => e.type === "message.part.updated")
+ .map((e) => e.properties.part as { type: string; text?: string })
+ .filter((p) => p.type === "text")
+ assert.deepEqual(textParts.map((p) => p.text), ["", "3+3=", "3+3=6", "3+3=6"])
+
+ // Usage attaches cost/tokens to the latest assistant message
+ const lastUpdate = messageUpdates[messageUpdates.length - 1].properties.info as { cost?: number; tokens?: { input: number } }
+ assert.equal(lastUpdate.cost, 1)
+ assert.equal(lastUpdate.tokens?.input, 9)
+
+ // Execution end -> idle status
+ const last = out[out.length - 1]
+ assert.equal(last.type, "session.status")
+ assert.deepEqual((last.properties.status as { type: string }).type, "idle")
+})
+
+test("execution.failed emits session.error before the idle status", () => {
+ const t = new V2EventTranslator()
+ const out = t.translate(ev("session.execution.failed", { sessionID: "s1", error: { data: { message: "boom" } } }))
+ assert.deepEqual(out.map((e) => e.type), ["session.error", "session.status"])
+ assert.deepEqual((out[0].properties.error as { message: string }).message, "boom")
+})
+
+test("execution.failed handles the live provider error shape {type, message}", () => {
+ // Verified against a v2 server: selecting a nonexistent model produced
+ // session.execution.failed with data.error = {type, message}.
+ const t = new V2EventTranslator()
+ const out = t.translate(
+ ev("session.execution.failed", {
+ sessionID: "s1",
+ error: { type: "provider.no-route", message: "Model unavailable: openrouter/definitely-not-a-real-model" },
+ }),
+ )
+ assert.deepEqual(out.map((e) => e.type), ["session.error", "session.status"])
+ assert.deepEqual((out[0].properties.error as { message: string }).message, "Model unavailable: openrouter/definitely-not-a-real-model")
+})
+
+test("inbox enqueue resolves the optimistic temp message with a real user message", () => {
+ const t = new V2EventTranslator()
+ const out = t.translate(
+ ev("session.inbox.enqueued", {
+ sessionID: "s1",
+ inboxID: "msg_u1",
+ item: { type: "user", payload: { text: "hello", files: [{ data: "YWJj", mime: "image/png", name: "a.png" }] } },
+ }),
+ )
+ assert.deepEqual(out.map((e) => e.type), ["message.updated", "message.part.updated", "message.part.updated"])
+ const info = out[0].properties.info as { id: string; role: string }
+ assert.deepEqual([info.id, info.role], ["msg_u1", "user"])
+ const filePart = out[2].properties.part as { type: string; url?: string }
+ assert.deepEqual([filePart.type, filePart.url], ["file", "data:image/png;base64,YWJj"])
+})
+
+test("permission and form events feed the pending registry and the prompt UI shapes", () => {
+ const t = new V2EventTranslator()
+ const perm = t.translate(
+ ev("permission.asked", { id: "per_1", sessionID: "s1", action: "bash", resources: ["ls"], source: { type: "tool", messageID: "msg_a1", callID: "c1" } }),
+ )
+ assert.equal(perm[0].type, "permission.asked")
+ const props = perm[0].properties as { permission: string; patterns: string[]; tool?: { callID: string } }
+ assert.deepEqual([props.permission, props.patterns], ["bash", ["ls"]])
+ assert.equal(props.tool?.callID, "c1")
+ assert.equal(pendingRequestInfo("per_1")?.sessionID, "s1")
+
+ // Live-verified envelope: v2 nests the form under `data.form`, unlike
+ // permission.asked (whose payload is the flat Permission.Request). Captured
+ // from a v2.0.16 server: evt_0d4be7612002H8CTkutJnzghGX. Reading the fields
+ // off `data` directly drops the event and no prompt ever renders.
+ const form = t.translate(
+ ev("form.created", {
+ form: {
+ id: "frm_1",
+ sessionID: "s1",
+ title: "Questions",
+ metadata: { kind: "question" },
+ fields: [
+ { key: "mode", type: "multiselect", title: "Mode", options: [{ value: "fast", label: "Fast" }, { value: "slow", label: "Slow" }] },
+ { key: "note", type: "string", title: "Note" },
+ ],
+ },
+ }),
+ )
+ assert.equal(form[0].type, "question.asked")
+ const q = (form[0].properties as { questions: Array<{ multiple: boolean; custom: boolean }> }).questions
+ assert.equal(q[0].multiple, true)
+ assert.equal(q[1].custom, true)
+ assert.equal(pendingRequestInfo("frm_1")?.sessionID, "s1")
+
+ const gone = t.translate(ev("form.replied", { id: "frm_1", sessionID: "s1" }))
+ assert.equal(gone[0].type, "question.replied")
+ assert.equal(pendingRequestInfo("frm_1"), undefined)
+})
+
+test("permission.v2 events preserve current PermissionV2 request and reply fields", () => {
+ const t = new V2EventTranslator()
+ const asked = t.translate(ev("permission.v2.asked", {
+ id: "per_v2", sessionID: "s2", action: "bash", resources: ["printf safe"],
+ metadata: { command: "printf safe" }, source: { type: "tool", messageID: "msg_a2", callID: "call_v2" },
+ }))
+ assert.equal(asked[0].type, "permission.asked")
+ const request = asked[0].properties as { permission: string; patterns: string[]; tool?: { callID: string } }
+ assert.deepEqual([request.permission, request.patterns, request.tool?.callID], ["bash", ["printf safe"], "call_v2"])
+ assert.equal(pendingRequestInfo("per_v2")?.sessionID, "s2")
+
+ const replied = t.translate(ev("permission.v2.replied", {
+ sessionID: "s2", requestID: "per_v2", reply: "once",
+ }))
+ assert.deepEqual(replied, [{ type: "permission.replied", properties: { sessionID: "s2", requestID: "per_v2" } }])
+ assert.equal(pendingRequestInfo("per_v2"), undefined)
+})
+
+test("form.created also maps when the payload is flat", () => {
+ const t = new V2EventTranslator()
+ const out = t.translate(
+ ev("form.created", { id: "frm_2", sessionID: "s2", title: "Questions", fields: [{ key: "q0", type: "string", title: "Answer" }] }),
+ )
+ assert.equal(out[0].type, "question.asked")
+ assert.equal(pendingRequestInfo("frm_2")?.sessionID, "s2")
+})
+
+test("formAnswerFromFields maps QuestionPrompt labels back to keyed values", () => {
+ const fields: V2FormField[] = [
+ { key: "mode", type: "multiselect", options: [{ value: "fast", label: "Fast" }, { value: "slow", label: "Slow" }] },
+ { key: "note", type: "string" },
+ { key: "ok", type: "boolean" },
+ { key: "count", type: "integer" },
+ ]
+ rememberForm({ id: "frm_x", sessionID: "s", title: "t", fields })
+ const answer = formAnswerFromFields(pendingRequestInfo("frm_x")?.fields, [["Fast", "Slow"], ["free text"], ["Yes"], ["7"]])
+ assert.deepEqual(answer, { mode: ["fast", "slow"], note: "free text", ok: true, count: 7 })
+})
+
+test("toQuestionRequest skips external fields and keeps answer alignment", () => {
+ const req = toQuestionRequest({
+ id: "frm_1",
+ sessionID: "s",
+ title: "t",
+ fields: [
+ { key: "hidden", type: "external" },
+ { key: "name", type: "string", title: "Name" },
+ ],
+ })
+ assert.equal(req.questions.length, 1)
+ assert.equal(req.questions[0].header, "Name")
+})
+
+test("toProviderCatalog rebuilds the v1 provider/model shape", () => {
+ const models: V2ModelInfo[] = [
+ {
+ id: "prov/model-a",
+ modelID: "model-a",
+ providerID: "prov",
+ name: "Model A",
+ capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
+ variants: [{ id: "none" }, { id: "high", settings: { reasoning: { effort: "high" } } }],
+ cost: [{ input: 1, output: 2 }],
+ status: "active",
+ enabled: true,
+ limit: { context: 100, output: 50 },
+ },
+ ]
+ const catalog = toProviderCatalog([{ id: "prov", name: "Prov" }], models, models[0])
+ assert.equal(catalog.all.length, 1)
+ assert.equal(catalog.connected.includes("prov"), true)
+ const m = catalog.all[0].models["prov/model-a"]
+ assert.equal(m.attachment, true)
+ assert.equal(m.tool_call, true)
+ assert.equal(m.reasoning, true)
+ assert.deepEqual(m.variants.high, { reasoningEffort: "high" })
+ assert.deepEqual(catalog.default, { prov: "prov/model-a" })
+})
diff --git a/src/lib/v2-shape.ts b/src/lib/v2-shape.ts
new file mode 100644
index 00000000..8fe5f9a5
--- /dev/null
+++ b/src/lib/v2-shape.ts
@@ -0,0 +1,837 @@
+// Conversion layer between the opencode v2 HTTP/SSE payloads and the internal
+// v1-style shapes the stores and chat UI consume (Session, Message, Part,
+// Event {type, properties}, ...). Keeping every mapping in one pure module
+// (only `import type` from sdk.ts, which is erased at runtime) makes it fully
+// unit-testable under plain `node --test` and keeps sdk.ts thin.
+//
+// Reference: docs/v2-api-map.md (verified against a live opencode v2 server).
+import type { Agent, Command, Event, FileEntry, Message, MessageWithParts, Part, Project, Session } from "./sdk"
+
+// --- raw v2 shapes (subset we consume) ---
+
+export interface V2LocationRef {
+ directory: string
+}
+
+export interface V2ModelRef {
+ id: string
+ providerID: string
+ variant?: string
+}
+
+export interface V2Tokens {
+ input: number
+ output: number
+ reasoning?: number
+ cache?: { read: number; write: number }
+}
+
+export interface V2SessionInfo {
+ id: string
+ parentID?: string
+ projectID: string
+ agent?: string
+ model?: V2ModelRef
+ title?: string
+ subpath?: string
+ revert?: { messageID: string; partID?: string }
+ location: V2LocationRef
+ time: { created: number; updated: number; archived?: number; idle?: number }
+}
+
+export interface V2ToolContent {
+ type: "text" | "file"
+ text?: string
+ uri?: string
+ mime?: string
+ name?: string | null
+}
+
+export interface V2ToolState {
+ status: "streaming" | "running" | "completed" | "error"
+ input?: unknown
+ content?: V2ToolContent[]
+ metadata?: Record
+ error?: { message?: string; data?: { message?: string } } & Record
+}
+
+export interface V2AssistantContentPart {
+ type: "text" | "reasoning" | "tool"
+ text?: string
+ id?: string
+ name?: string
+ state?: V2ToolState
+ time?: { created?: number; ran?: number; completed?: number }
+}
+
+export interface V2FileAttachment {
+ data?: string
+ mime?: string
+ uri?: string
+ name?: string
+ source?: { type: string; path?: string }
+}
+
+export interface V2Message {
+ id: string
+ sessionID?: string
+ type: string
+ time: { created: number; completed?: number }
+ text?: string
+ files?: V2FileAttachment[]
+ agent?: string
+ model?: V2ModelRef
+ content?: V2AssistantContentPart[]
+ finish?: string
+ cost?: number
+ tokens?: V2Tokens
+ error?: { message?: string; data?: { message?: string } } & Record
+ outcome?: string
+}
+
+export interface V2PermissionRequest {
+ id: string
+ sessionID: string
+ action: string
+ resources: string[]
+ metadata?: Record
+ source?: { type: string; messageID?: string; callID?: string }
+}
+
+export interface V2FormOption {
+ value: string
+ label: string
+ description?: string
+}
+
+export interface V2FormField {
+ key: string
+ type: "string" | "number" | "integer" | "boolean" | "multiselect" | "external"
+ title?: string
+ description?: string
+ options?: V2FormOption[]
+ custom?: boolean
+}
+
+export interface V2FormInfo {
+ id: string
+ sessionID: string
+ title: string
+ fields: V2FormField[]
+}
+
+export interface V2AgentInfo {
+ id: string
+ name: string
+ description?: string
+ mode: "subagent" | "primary" | "all"
+ hidden?: boolean
+ color?: string
+ model?: V2ModelRef
+ steps?: number
+}
+
+export interface V2CommandInfo {
+ name: string
+ description?: string
+}
+
+export interface V2ModelInfo {
+ id: string
+ modelID: string
+ providerID: string
+ name: string
+ capabilities: { tools: boolean; input: string[]; output: string[] }
+ variants: Array<{ id: string; settings?: { reasoning?: { enabled?: boolean; effort?: string } } }>
+ cost?: Array<{ input: number; output: number }>
+ status?: "alpha" | "beta" | "deprecated" | "active"
+ enabled?: boolean
+ limit: { context: number; output: number }
+}
+
+export interface V2ProviderInfo {
+ id: string
+ name: string
+ activation?: "auto" | "enabled" | "disabled"
+}
+
+export interface V2ProjectInfo {
+ id: string
+ canonical: string
+}
+
+export interface V2FsEntry {
+ path: string
+ type: "file" | "directory"
+}
+
+export interface V2RawEvent {
+ id?: string
+ created?: number
+ type: string
+ data?: Record
+}
+
+// --- pending-request registry -------------------------------------------------
+// v2 permission/form replies are session-scoped (`/api/session/{id}/...`), but
+// the app calls `permission.reply(requestID)` globally. Remember which session
+// owns each request (from SSE events and list endpoints — both flow through
+// this module) so the SDK can build the right URL. Forms additionally keep
+// their fields so `question.reply(answers)` can map selected labels back to
+// the keyed Form.Answer object v2 expects.
+
+interface PendingRequest {
+ sessionID: string
+ fields?: V2FormField[]
+}
+
+const pendingRequests = new Map()
+
+export function rememberPermissionRequest(req: V2PermissionRequest): void {
+ pendingRequests.set(req.id, { sessionID: req.sessionID })
+}
+
+export function rememberForm(form: V2FormInfo): void {
+ pendingRequests.set(form.id, { sessionID: form.sessionID, fields: answerFields(form.fields) })
+}
+
+export function forgetPendingRequest(id: string): void {
+ pendingRequests.delete(id)
+}
+
+export function pendingRequestInfo(id: string): PendingRequest | undefined {
+ return pendingRequests.get(id)
+}
+
+export function clearPendingRequests(): void {
+ pendingRequests.clear()
+}
+
+// --- conversions ---------------------------------------------------------------
+
+export function toSession(info: V2SessionInfo): Session {
+ return {
+ id: info.id,
+ slug: "",
+ projectID: info.projectID,
+ directory: info.location?.directory ?? "",
+ parentID: info.parentID,
+ title: info.title ?? "",
+ version: "",
+ time: {
+ created: info.time?.created ?? 0,
+ updated: info.time?.updated ?? 0,
+ archived: info.time?.archived,
+ },
+ revert: info.revert ? { messageID: info.revert.messageID, partID: info.revert.partID } : undefined,
+ }
+}
+
+function structuredErrorMessage(error: unknown): string {
+ if (!error) return "Session error occurred"
+ if (typeof error === "string") return error
+ const e = error as { message?: string; data?: { message?: string } }
+ return e.message || e.data?.message || JSON.stringify(error)
+}
+
+function attachmentUrl(f: V2FileAttachment): string {
+ if (f.uri) return f.uri
+ if (f.data && f.mime) return `data:${f.mime};base64,${f.data}`
+ if (f.source?.path) return f.source.path
+ return ""
+}
+
+function userParts(messageID: string, msg: { text?: string; files?: V2FileAttachment[] }): Part[] {
+ const parts: Part[] = []
+ if (msg.text) {
+ parts.push({ id: `${messageID}-text`, messageID, type: "text", text: msg.text })
+ }
+ for (const f of msg.files || []) {
+ const url = attachmentUrl(f)
+ if (!url) continue
+ parts.push({ id: `${messageID}-file-${parts.length}`, messageID, type: "file", mime: f.mime, url, filename: f.name })
+ }
+ return parts
+}
+
+function toolOutput(content: V2ToolContent[] | undefined): string | undefined {
+ if (!content || content.length === 0) return undefined
+ const texts = content.map((c) => (c.type === "text" ? c.text : c.uri ? `[${c.name || c.uri}]` : "")).filter(Boolean)
+ return texts.length > 0 ? texts.join("\n") : undefined
+}
+
+function mapToolState(state: V2ToolState | undefined): Part["state"] {
+ if (!state) return { status: "pending" }
+ const title = typeof state.metadata?.title === "string" ? (state.metadata.title as string) : undefined
+ if (state.status === "streaming" || state.status === "running") {
+ return { status: "running", input: state.input, title }
+ }
+ if (state.status === "completed") {
+ return { status: "completed", input: state.input, output: toolOutput(state.content), title }
+ }
+ return {
+ status: "error",
+ input: state.input,
+ output: toolOutput(state.content),
+ title,
+ error: { message: structuredErrorMessage(state.error) },
+ }
+}
+
+function assistantParts(messageID: string, content: V2AssistantContentPart[] | undefined): Part[] {
+ const parts: Part[] = []
+ for (let i = 0; i < (content || []).length; i++) {
+ const c = content![i]
+ if (c.type === "text" || c.type === "reasoning") {
+ parts.push({ id: `${messageID}-${c.type}-${i}`, messageID, type: c.type, text: c.text ?? "" })
+ continue
+ }
+ if (c.type === "tool") {
+ parts.push({
+ id: `${messageID}-tool-${c.id || i}`,
+ messageID,
+ type: "tool",
+ tool: c.name,
+ callID: c.id,
+ state: mapToolState(c.state),
+ time: { start: c.time?.created, end: c.time?.completed },
+ })
+ }
+ }
+ return parts
+}
+
+// v2 projects a session timeline of typed messages (user / assistant / idle /
+// switch markers / ...). The chat UI only understands user+assistant messages
+// with parts, so markers are dropped and the rest reshaped. Input is expected
+// oldest-first (order=asc); output preserves that order.
+export function toMessageWithParts(messages: V2Message[]): MessageWithParts[] {
+ const out: MessageWithParts[] = []
+ for (const m of messages) {
+ if (m.type === "user") {
+ out.push({
+ info: {
+ id: m.id,
+ sessionID: m.sessionID ?? "",
+ role: "user",
+ time: { created: m.time?.created ?? 0 },
+ agent: m.agent,
+ model: m.model ? { providerID: m.model.providerID, modelID: m.model.id } : undefined,
+ },
+ parts: userParts(m.id, m),
+ })
+ continue
+ }
+ if (m.type === "assistant") {
+ out.push({
+ info: {
+ id: m.id,
+ sessionID: m.sessionID ?? "",
+ role: "assistant",
+ time: { created: m.time?.created ?? 0, completed: m.time?.completed },
+ agent: m.agent,
+ modelID: m.model?.id,
+ providerID: m.model?.providerID,
+ cost: m.cost,
+ tokens: m.tokens
+ ? {
+ input: m.tokens.input,
+ output: m.tokens.output,
+ reasoning: m.tokens.reasoning,
+ cache: m.tokens.cache,
+ }
+ : undefined,
+ error: m.error ? { message: structuredErrorMessage(m.error) } : undefined,
+ finish: m.finish,
+ },
+ parts: assistantParts(m.id, m.content),
+ })
+ }
+ // idle / agent-switched / model-switched / compaction / system / synthetic
+ // / shell / skill markers carry no chat content - skipped.
+ }
+ return out
+}
+
+export function toPermissionRequest(req: V2PermissionRequest) {
+ return {
+ id: req.id,
+ sessionID: req.sessionID,
+ permission: req.action,
+ patterns: req.resources || [],
+ metadata: req.metadata || {},
+ tool: req.source?.type === "tool" && req.source.messageID
+ ? { messageID: req.source.messageID, callID: req.source.callID ?? "" }
+ : undefined,
+ }
+}
+
+// Fields that actually render as questions (external fields have no UI here);
+// the same filtered list keys the answer mapping so indices stay aligned.
+function answerFields(fields: V2FormField[]): V2FormField[] {
+ return (fields || []).filter((f) => f.type !== "external")
+}
+
+export function toQuestionRequest(form: V2FormInfo) {
+ const questions = answerFields(form.fields).map((f) => {
+ const options = (f.options || []).map((o) => ({ label: o.label || o.value, description: o.description || "" }))
+ if (f.type === "boolean") {
+ return {
+ question: f.description || f.title || f.key,
+ header: f.title || f.key,
+ options: [
+ { label: "Yes", description: "" },
+ { label: "No", description: "" },
+ ],
+ multiple: false,
+ custom: false,
+ }
+ }
+ const hasOptions = options.length > 0
+ return {
+ question: f.description || f.title || f.key,
+ header: f.title || f.key,
+ options,
+ multiple: f.type === "multiselect",
+ custom: f.custom !== false && (f.type === "string" || f.type === "number" || f.type === "integer" || !hasOptions),
+ }
+ })
+ return {
+ id: form.id,
+ sessionID: form.sessionID,
+ questions,
+ tool: undefined,
+ }
+}
+
+// Map QuestionPrompt's label-based answers back to v2's keyed Form.Answer.
+export function formAnswerFromFields(fields: V2FormField[] | undefined, answers: string[][]): Record {
+ const answer: Record = {}
+ for (let i = 0; i < (fields || []).length; i++) {
+ const f = fields![i]
+ const selected = answers[i] || []
+ const valueOf = (label: string): string => {
+ const opt = (f.options || []).find((o) => (o.label || o.value) === label)
+ return opt ? opt.value : label
+ }
+ if (selected.length === 0) continue
+ if (f.type === "multiselect") {
+ answer[f.key] = selected.map(valueOf)
+ } else if (f.type === "boolean") {
+ answer[f.key] = selected[0] === "Yes" || selected[0] === "true"
+ } else if (f.type === "number" || f.type === "integer") {
+ const n = Number(selected[0])
+ if (!Number.isNaN(n)) answer[f.key] = n
+ } else {
+ answer[f.key] = valueOf(selected[0])
+ }
+ }
+ return answer
+}
+
+export function toAgent(a: V2AgentInfo): Agent {
+ return {
+ name: a.id,
+ description: a.description,
+ mode: a.mode,
+ hidden: a.hidden,
+ color: a.color,
+ model: a.model ? { modelID: a.model.id, providerID: a.model.providerID } : undefined,
+ options: {},
+ steps: a.steps,
+ }
+}
+
+export function toCommand(c: V2CommandInfo): Command {
+ return { name: c.name, description: c.description, template: "", hints: [], mcp: false }
+}
+
+export function toProject(p: V2ProjectInfo): Project {
+ return { id: p.id, name: p.canonical, path: { cwd: p.canonical, root: p.canonical, absolute: p.canonical } }
+}
+
+export function toFileEntries(location: V2LocationRef | undefined, entries: V2FsEntry[]): FileEntry[] {
+ const base = location?.directory ?? ""
+ return entries.map((e) => ({
+ name: e.path.split("/").filter(Boolean).pop() || e.path,
+ path: e.path,
+ absolute: e.path.startsWith("/") ? e.path : `${base}/${e.path}`.replace(/\/{2,}/g, "/"),
+ type: e.type,
+ ignored: false,
+ }))
+}
+
+// v2 splits providers and models across two endpoints; rebuild the v1-style
+// catalog object ModelPicker/VariantPicker consume.
+export function toProviderCatalog(
+ providers: V2ProviderInfo[],
+ models: V2ModelInfo[],
+ defaultModel: V2ModelInfo | null,
+): {
+ all: Array<{ id: string; name: string; models: Record }>
+ default: Record
+ connected: string[]
+} {
+ const byProvider = new Map }>()
+ for (const p of providers || []) {
+ byProvider.set(p.id, { id: p.id, name: p.name, models: {} })
+ }
+ const connected = new Set()
+ for (const m of models || []) {
+ if (!byProvider.has(m.providerID)) {
+ byProvider.set(m.providerID, { id: m.providerID, name: m.providerID, models: {} })
+ }
+ if (m.enabled === false) continue
+ connected.add(m.providerID)
+ const entry = byProvider.get(m.providerID)!
+ const variants: Record = {}
+ for (const v of m.variants || []) {
+ variants[v.id] = { reasoningEffort: v.settings?.reasoning?.effort }
+ }
+ entry.models[m.id] = {
+ id: m.id,
+ name: m.name || m.modelID,
+ attachment: (m.capabilities?.input || []).includes("image"),
+ reasoning:
+ (m.capabilities?.input || []).includes("reasoning") ||
+ (m.variants || []).some((v) => v.settings?.reasoning && v.settings.reasoning.enabled !== false),
+ tool_call: Boolean(m.capabilities?.tools),
+ cost: m.cost?.[0] ? { input: m.cost[0].input, output: m.cost[0].output } : undefined,
+ limit: { context: m.limit?.context ?? 0, output: m.limit?.output ?? 0 },
+ status: m.status,
+ variants: Object.keys(variants).length > 0 ? variants : undefined,
+ }
+ }
+ const defaultByProvider: Record = {}
+ if (defaultModel) defaultByProvider[defaultModel.providerID] = defaultModel.id
+ return {
+ all: Array.from(byProvider.values()).filter((p) => Object.keys(p.models).length > 0),
+ default: defaultByProvider,
+ connected: Array.from(connected),
+ }
+}
+
+// --- SSE event translation -----------------------------------------------------
+// v2 streams typed execution events (session.text.delta, session.tool.called,
+// ...) scoped by assistantMessageID. The app's stores consume the v1 taxonomy
+// (message.updated / message.part.updated / session.status / ...), so the
+// translator accumulates streamed content client-side and re-emits it as
+// part/message updates. Docs: docs/v2-api-map.md "Event taxonomy".
+
+function ev(type: string, properties: Record): Event {
+ return { type, properties }
+}
+
+interface ToolBuf {
+ sessionID: string
+ messageID: string
+ name: string
+ input: string
+ start?: number
+}
+
+export class V2EventTranslator {
+ private assistants = new Map()
+ private textBuf = new Map()
+ private reasoningBuf = new Map()
+ private tools = new Map()
+
+ reset(): void {
+ this.assistants.clear()
+ this.textBuf.clear()
+ this.reasoningBuf.clear()
+ this.tools.clear()
+ }
+
+ // Content events can arrive before session.step.started for a step; emit a
+ // synthesized message.updated the first time we see an assistantMessageID so
+ // the chat always has a message bubble to attach parts to.
+ private ensureAssistant(sessionID: string, messageID: string, created?: number): Event[] {
+ if (this.assistants.has(messageID)) return []
+ const msg: Message = {
+ id: messageID,
+ sessionID,
+ role: "assistant",
+ time: { created: created ?? Date.now() },
+ }
+ this.assistants.set(messageID, msg)
+ return [ev("message.updated", { info: msg })]
+ }
+
+ private assistantMessage(sessionID: string, messageID: string, created?: number): Message {
+ const existing = this.assistants.get(messageID)
+ if (existing) return existing
+ const msg: Message = {
+ id: messageID,
+ sessionID,
+ role: "assistant",
+ time: { created: created ?? Date.now() },
+ }
+ this.assistants.set(messageID, msg)
+ return msg
+ }
+
+ private partEvent(buf: Map, key: string, part: Part): Event[] {
+ buf.set(key, buf.get(key) === undefined ? (part.text ?? "") : buf.get(key) + (part.text ?? ""))
+ part = { ...part, text: buf.get(key) }
+ return [ev("message.part.updated", { part })]
+ }
+
+ translate(raw: V2RawEvent): Event[] {
+ const d = raw.data || {}
+ const sessionID = d.sessionID as string | undefined
+
+ switch (raw.type) {
+ case "server.connected":
+ case "project.updated":
+ case "session.inbox.delivered":
+ case "session.viewed":
+ case "session.metadata.updated":
+ case "session.model.selected":
+ case "session.agent.selected":
+ case "session.usage.recorded":
+ return []
+
+ case "session.execution.started":
+ if (!sessionID) return []
+ return [ev("session.status", { sessionID, status: { type: "busy" } })]
+
+ case "session.execution.succeeded":
+ case "session.execution.interrupted": {
+ if (!sessionID) return []
+ return [ev("session.status", { sessionID, status: { type: "idle" } })]
+ }
+
+ case "session.execution.failed": {
+ if (!sessionID) return []
+ const out: Event[] = []
+ const errorMessage = structuredErrorMessage(d.error)
+ out.push(ev("session.error", { sessionID, error: { message: errorMessage } }))
+ let latest: Message | undefined
+ for (const m of this.assistants.values()) {
+ if (m.sessionID === sessionID) latest = m
+ }
+ if (latest) {
+ const msg: Message = { ...latest, error: { message: errorMessage } }
+ this.assistants.set(latest.id, msg)
+ out.push(ev("message.updated", { info: msg }))
+ }
+ out.push(ev("session.status", { sessionID, status: { type: "idle" } }))
+ return out
+ }
+
+ case "session.error":
+ if (!sessionID) return []
+ return [ev("session.error", { sessionID, error: { message: structuredErrorMessage(d.error) } })]
+
+ case "session.step.started": {
+ if (!sessionID || !d.assistantMessageID) return []
+ const msg: Message = {
+ id: d.assistantMessageID,
+ sessionID,
+ role: "assistant",
+ time: { created: d.started ?? raw.created ?? Date.now() },
+ agent: d.agent,
+ modelID: d.model?.id,
+ providerID: d.model?.providerID,
+ }
+ this.assistants.set(msg.id, msg)
+ return [ev("message.updated", { info: msg })]
+ }
+
+ case "session.step.ended": {
+ if (!d.assistantMessageID) return []
+ const base = this.assistantMessage(sessionID ?? "", d.assistantMessageID, raw.created)
+ const msg: Message = {
+ ...base,
+ time: { ...base.time, completed: raw.created ?? Date.now() },
+ finish: d.finish,
+ cost: d.cost ?? base.cost,
+ tokens: d.tokens ? { input: d.tokens.input, output: d.tokens.output, reasoning: d.tokens.reasoning, cache: d.tokens.cache } : base.tokens,
+ }
+ this.assistants.set(msg.id, msg)
+ return [ev("message.updated", { info: msg })]
+ }
+
+ case "session.usage.updated": {
+ // Usage lands per-run; attribute it to the latest assistant message we
+ // synthesized for this session (what the token/cost UI reads).
+ if (!sessionID) return []
+ let latest: Message | undefined
+ for (const m of this.assistants.values()) {
+ if (m.sessionID === sessionID) latest = m
+ }
+ if (!latest) return []
+ const msg: Message = {
+ ...latest,
+ cost: d.cost ?? latest.cost,
+ tokens: d.tokens ? { input: d.tokens.input, output: d.tokens.output, reasoning: d.tokens.reasoning, cache: d.tokens.cache } : latest.tokens,
+ }
+ this.assistants.set(latest.id, msg)
+ return [ev("message.updated", { info: msg })]
+ }
+
+ case "session.text.started":
+ case "session.text.delta":
+ case "session.text.ended": {
+ if (!sessionID || !d.assistantMessageID) return []
+ const pre = this.ensureAssistant(sessionID, d.assistantMessageID, raw.created)
+ const key = `${d.assistantMessageID}|${d.ordinal ?? 0}`
+ if (raw.type === "session.text.started") {
+ if (!this.textBuf.has(key)) this.textBuf.set(key, "")
+ return [...pre, ev("message.part.updated", { part: { id: `text-${key}`, sessionID, messageID: d.assistantMessageID, type: "text", text: "" } })]
+ }
+ const chunk = raw.type === "session.text.delta" ? String(d.delta ?? "") : String(d.text ?? "")
+ const isEnd = raw.type === "session.text.ended"
+ const acc = (this.textBuf.get(key) ?? "") + chunk
+ this.textBuf.set(key, acc)
+ if (isEnd && d.text !== undefined) this.textBuf.set(key, String(d.text))
+ return [...pre, ev("message.part.updated", { part: { id: `text-${key}`, sessionID, messageID: d.assistantMessageID, type: "text", text: this.textBuf.get(key) } })]
+ }
+
+ case "session.reasoning.started":
+ case "session.reasoning.delta":
+ case "session.reasoning.ended": {
+ if (!sessionID || !d.assistantMessageID) return []
+ const pre = this.ensureAssistant(sessionID, d.assistantMessageID, raw.created)
+ const key = `${d.assistantMessageID}|${d.ordinal ?? 0}`
+ if (raw.type === "session.reasoning.started") {
+ if (!this.reasoningBuf.has(key)) this.reasoningBuf.set(key, "")
+ return [...pre, ev("message.part.updated", { part: { id: `reasoning-${key}`, sessionID, messageID: d.assistantMessageID, type: "reasoning", text: "" } })]
+ }
+ const chunk = raw.type === "session.reasoning.delta" ? String(d.delta ?? "") : String(d.text ?? "")
+ const acc = (this.reasoningBuf.get(key) ?? "") + chunk
+ this.reasoningBuf.set(key, acc)
+ if (raw.type === "session.reasoning.ended" && d.text !== undefined) this.reasoningBuf.set(key, String(d.text))
+ return [...pre, ev("message.part.updated", { part: { id: `reasoning-${key}`, sessionID, messageID: d.assistantMessageID, type: "reasoning", text: this.reasoningBuf.get(key) } })]
+ }
+
+ case "session.tool.input.started":
+ case "session.tool.input.delta":
+ case "session.tool.input.ended":
+ case "session.tool.called":
+ case "session.tool.progress":
+ case "session.tool.success":
+ case "session.tool.failed": {
+ if (!sessionID || !d.id || !d.assistantMessageID) return []
+ const pre = this.ensureAssistant(sessionID, d.assistantMessageID, raw.created)
+ let buf = this.tools.get(d.id)
+ if (!buf) {
+ buf = { sessionID, messageID: d.assistantMessageID, name: d.name ?? "tool", input: "" }
+ this.tools.set(d.id, buf)
+ }
+ if (d.name) buf.name = d.name
+ if (raw.type === "session.tool.input.delta") buf.input += String(d.delta ?? "")
+ if (raw.type === "session.tool.input.ended" && d.text !== undefined) buf.input = String(d.text)
+
+ let state: Part["state"]
+ if (raw.type === "session.tool.success") {
+ state = { status: "completed", input: parsedToolInput(buf), output: toolOutput(d.content), title: typeof d.metadata?.title === "string" ? d.metadata.title : undefined, time: { start: buf.start, end: raw.created } }
+ } else if (raw.type === "session.tool.failed") {
+ state = { status: "error", input: parsedToolInput(buf), output: toolOutput(d.content), error: { message: structuredErrorMessage(d.error) }, time: { start: buf.start, end: raw.created } }
+ } else {
+ state = { status: "running", input: raw.type === "session.tool.called" ? d.input : parsedToolInput(buf), title: typeof d.metadata?.title === "string" ? d.metadata.title : undefined, time: { start: buf.start } }
+ }
+ if (raw.type === "session.tool.called") buf.start = buf.start ?? raw.created
+ const part: Part = { id: `tool-${d.id}`, sessionID, messageID: d.assistantMessageID, type: "tool", tool: buf.name, callID: d.id, state }
+ return [...pre, ev("message.part.updated", { part })]
+ }
+
+ case "session.inbox.enqueued": {
+ // The projected user message id equals the inbox item id (verified live),
+ // so emitting it here resolves the app's optimistic `temp-` message.
+ if (!sessionID || !d.inboxID) return []
+ const item = d.item || {}
+ const info: Message = {
+ id: d.inboxID,
+ sessionID,
+ role: "user",
+ time: { created: raw.created ?? Date.now() },
+ }
+ const out: Event[] = [ev("message.updated", { info })]
+ for (const part of userParts(d.inboxID, item.payload || item)) {
+ out.push(ev("message.part.updated", { part }))
+ }
+ return out
+ }
+
+ case "session.inbox.cancelled":
+ if (!sessionID || !d.inboxID) return []
+ return [ev("message.removed", { sessionID, messageID: d.inboxID })]
+
+ case "session.created": {
+ if (!d.id) return []
+ return [ev("session.created", { info: toSession(d as unknown as V2SessionInfo) })]
+ }
+
+ case "session.updated":
+ case "session.renamed":
+ case "session.moved":
+ case "session.forked": {
+ if (!d.id) return []
+ return [ev("session.updated", { info: toSession(d as unknown as V2SessionInfo) })]
+ }
+
+ case "permission.asked":
+ case "permission.v2.asked": {
+ if (!d.id || !d.sessionID) return []
+ rememberPermissionRequest(d as V2PermissionRequest)
+ return [ev("permission.asked", toPermissionRequest(d as V2PermissionRequest))]
+ }
+
+ case "permission.replied":
+ case "permission.rejected":
+ case "permission.v2.replied": {
+ const requestID = (d.requestID ?? d.id) as string | undefined
+ if (!requestID) return []
+ const known = pendingRequestInfo(requestID)
+ forgetPendingRequest(requestID)
+ const sid = d.sessionID ?? known?.sessionID
+ if (!sid) return []
+ return [ev("permission.replied", { sessionID: sid, requestID })]
+ }
+
+ case "form.created": {
+ // v2 nests the form under `data.form` — unlike `permission.asked`, whose
+ // payload is the flat Permission.Request. Live-verified on v2.0.16
+ // (evt_0d4be7612002H8CTkutJnzghGX): data = {form: {id, sessionID, title, fields}}.
+ // Reading `d.id` here returns undefined, so the event was dropped silently
+ // and the question prompt never rendered.
+ const form = (d.form ?? d) as V2FormInfo
+ if (!form.id || !form.sessionID) return []
+ rememberForm(form)
+ return [ev("question.asked", toQuestionRequest(form))]
+ }
+
+ case "form.replied": {
+ if (!d.id) return []
+ const known = pendingRequestInfo(d.id)
+ forgetPendingRequest(d.id)
+ const sid = d.sessionID ?? known?.sessionID
+ if (!sid) return []
+ return [ev("question.replied", { sessionID: sid, requestID: d.id })]
+ }
+
+ case "form.cancelled": {
+ if (!d.id) return []
+ const known = pendingRequestInfo(d.id)
+ forgetPendingRequest(d.id)
+ const sid = d.sessionID ?? known?.sessionID
+ if (!sid) return []
+ return [ev("question.rejected", { sessionID: sid, requestID: d.id })]
+ }
+
+ default:
+ return []
+ }
+ }
+}
+
+function parsedToolInput(buf: ToolBuf): unknown {
+ if (!buf.input) return undefined
+ try {
+ return JSON.parse(buf.input)
+ } catch {
+ return buf.input
+ }
+}
diff --git a/src/stores/connections.ts b/src/stores/connections.ts
index 2fb33de5..099de935 100644
--- a/src/stores/connections.ts
+++ b/src/stores/connections.ts
@@ -2,7 +2,8 @@ import { create } from "zustand"
import * as SecureStore from "expo-secure-store"
import * as Crypto from "expo-crypto"
import type { ServerConnection, ConnectionType } from "../lib/types"
-import { createClient, type Client, type Project } from "../lib/sdk"
+import { createClient, probeServer, type Client, type Project } from "../lib/sdk"
+import type { ServerApi } from "../lib/api-detect"
import { addBreadcrumb } from "../lib/sentry"
import { AnalyticsEvent, classifyConnectionError, track, type ConnectionTestSource } from "../lib/analytics"
import { buildAuth } from "../lib/auth"
@@ -23,6 +24,7 @@ const CONNECTION_TEST_TIMEOUT_MS = 12_000
interface ClientBase {
baseUrl: string
auth?: { username: string; password: string }
+ api?: ServerApi
}
interface ConnectionsState {
@@ -47,7 +49,7 @@ interface ConnectionsState {
connection: ServerConnection,
source: ConnectionTestSource,
password?: string,
- ) => Promise<{ ok: boolean; error?: string }>
+ ) => Promise<{ ok: boolean; api?: ServerApi; serverVersion?: string; error?: string }>
updateConnection: (id: string, updates: Partial, password?: string) => Promise
refreshProject: () => Promise
// Create a one-off client pointing at a specific directory (for cross-project operations).
@@ -67,12 +69,33 @@ function buildClient(
url: string,
directory?: string,
auth?: { username: string; password: string },
+ api?: ServerApi,
): { client: Client; base: ClientBase } {
- const base: ClientBase = { baseUrl: url, auth }
- const client = createClient({ baseUrl: url, directory, auth })
+ const base: ClientBase = { baseUrl: url, auth, api }
+ const client = createClient({ baseUrl: url, directory, auth, api })
return { client, base }
}
+// Decide which transport a connection needs. A recorded probe is reused; a
+// connection saved before this existed (or saved while the server was down) is
+// probed once here and the answer is written back onto the stored record. When
+// the probe cannot classify the server we keep the previous behaviour (v2)
+// instead of refusing to load a connection the user can still see and edit.
+async function resolveApi(
+ connection: ServerConnection,
+ auth?: { username: string; password: string },
+): Promise<{ api?: ServerApi; resolved: boolean }> {
+ if (connection.api) return { api: connection.api, resolved: false }
+ try {
+ const identity = await probeServer({ baseUrl: connection.url, directory: connection.directory, auth })
+ connection.api = identity.api
+ connection.serverVersion = identity.version
+ return { api: identity.api, resolved: true }
+ } catch {
+ return { api: undefined, resolved: false }
+ }
+}
+
export const useConnections = create((set, get) => ({
connections: [],
activeConnection: null,
@@ -100,25 +123,16 @@ export const useConnections = create((set, get) => ({
// Create client for active connection
let client: Client | null = null
let base: ClientBase | null = null
- let project: Project | null = null
- let home: string | null = null
if (active) {
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${active.id}`)
const auth = buildAuth(active.username, password)
- const built = buildClient(active.url, active.directory, auth)
+ const { api, resolved } = await resolveApi(active, auth)
+ const built = buildClient(active.url, active.directory, auth, api)
client = built.client
base = built.base
- // Fetch current project info and server paths
- try {
- const [proj, paths] = await Promise.all([
- client.project.current().catch(() => null),
- client.path.get().catch(() => null),
- ])
- project = proj
- home = paths?.home || null
- } catch {
- // Server might be offline
- }
+ // Persist a probe that was only just discovered, so the next launch
+ // picks the same transport without probing again.
+ if (resolved) await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
}
set({
@@ -126,11 +140,25 @@ export const useConnections = create((set, get) => ({
activeConnection: active,
client,
clientBase: base,
- currentProject: project,
- serverHome: home,
+ currentProject: null,
+ serverHome: null,
recentDirectories,
isLoading: false,
})
+
+ // Publish the client before optional project/path metadata. SSE startup
+ // must not wait on these unrelated calls: on a slow mobile route either
+ // request can spend its full timeout before realtime recovery begins.
+ if (client) {
+ const requestClient = client
+ void Promise.all([
+ requestClient.project.current().catch(() => null),
+ requestClient.path.get().catch(() => null),
+ ]).then(([project, paths]) => {
+ if (get().client !== requestClient) return
+ set({ currentProject: project, serverHome: paths?.home || null })
+ })
+ }
} catch (error) {
set({ error: "Failed to load connections", isLoading: false })
}
@@ -158,31 +186,26 @@ export const useConnections = create((set, get) => ({
let base = get().clientBase
let activeConnection = get().activeConnection
- let project = get().currentProject
- let serverHome = get().serverHome
-
if (newConnection.active) {
activeConnection = newConnection
const auth = buildAuth(newConnection.username, password)
- const built = buildClient(newConnection.url, newConnection.directory, auth)
- client = built.client
+ const { api } = await resolveApi(newConnection, auth)
+ const built = buildClient(newConnection.url, newConnection.directory, auth, api)
+ const requestClient = built.client
+ client = requestClient
base = built.base
-
- // Fetch server metadata so loadSessions can use clientForDirectory(serverHome)
- // immediately after the connection is added (same as setActiveConnection does).
- try {
- const [proj, paths] = await Promise.all([
- client.project.current().catch(() => null),
- client.path.get().catch(() => null),
- ])
- project = proj
- serverHome = paths?.home || null
- } catch {
- // Server might be unreachable; proceed without metadata
- }
+ set({ connections, activeConnection, client, clientBase: base, currentProject: null, serverHome: null })
+ void Promise.all([
+ requestClient.project.current().catch(() => null),
+ requestClient.path.get().catch(() => null),
+ ]).then(([project, paths]) => {
+ if (get().client !== requestClient) return
+ set({ currentProject: project, serverHome: paths?.home || null })
+ })
+ return
}
- set({ connections, activeConnection, client, clientBase: base, currentProject: project, serverHome })
+ set({ connections, activeConnection, client, clientBase: base })
},
removeConnection: async (id) => {
@@ -202,7 +225,9 @@ export const useConnections = create((set, get) => ({
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${newActive.id}`)
const auth = buildAuth(newActive.username, password)
- const built = buildClient(newActive.url, newActive.directory, auth)
+ const { api, resolved } = await resolveApi(newActive, auth)
+ const built = buildClient(newActive.url, newActive.directory, auth, api)
+ if (resolved) await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
set({ connections, activeConnection: newActive, client: built.client, clientBase: built.base })
} else {
set({ connections, activeConnection: null, client: null, clientBase: null })
@@ -223,52 +248,57 @@ export const useConnections = create((set, get) => ({
const active = connections.find((c) => c.id === id) || null
let client: Client | null = null
let base: ClientBase | null = null
- let project: Project | null = null
- let home: string | null = null
+ let requestClient: Client | null = null
if (active) {
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${active.id}`)
const auth = buildAuth(active.username, password)
- const built = buildClient(active.url, active.directory, auth)
- client = built.client
+ const { api } = await resolveApi(active, auth)
+ const built = buildClient(active.url, active.directory, auth, api)
+ requestClient = built.client
+ client = requestClient
base = built.base
- try {
- const [proj, paths] = await Promise.all([
- client.project.current().catch(() => null),
- client.path.get().catch(() => null),
- ])
- project = proj
- home = paths?.home || null
- } catch {
- // Server might be offline
- }
-
// Update last connected time
active.lastConnected = Date.now()
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
}
- set({ connections, activeConnection: active, client, clientBase: base, currentProject: project, serverHome: home })
+ set({ connections, activeConnection: active, client, clientBase: base, currentProject: null, serverHome: null })
+ if (requestClient) {
+ void Promise.all([
+ requestClient.project.current().catch(() => null),
+ requestClient.path.get().catch(() => null),
+ ]).then(([project, paths]) => {
+ if (get().client !== requestClient) return
+ set({ currentProject: project, serverHome: paths?.home || null })
+ })
+ }
addBreadcrumb({
category: "connection",
message: active ? `active connection set: ${active.type}` : "active connection cleared",
- data: { id: active?.id, type: active?.type, hasProject: Boolean(project) },
+ data: { id: active?.id, type: active?.type, hasProject: false },
})
},
testConnection: async (connection, source, password) => {
track(AnalyticsEvent.ConnectionAttempted, { source })
try {
+ const auth = buildAuth(connection.username, password)
+ // Probe before anything else. Against a v1 server the v2 health route is not
+ // a route at all — it falls through to the web SPA and answers 200 with HTML,
+ // which used to read as "connected" and then failed on every real request.
+ const identity = await probeServer({ baseUrl: connection.url, directory: connection.directory, auth })
const client = createClient({
baseUrl: connection.url,
directory: connection.directory,
- auth: buildAuth(connection.username, password),
+ auth,
+ api: identity.api,
})
await client.global.health(CONNECTION_TEST_TIMEOUT_MS)
track(AnalyticsEvent.ConnectionSucceeded, { source })
- return { ok: true }
+ return { ok: true, api: identity.api, serverVersion: identity.version }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
track(AnalyticsEvent.ConnectionFailed, { source, error_class: classifyConnectionError(message) })
@@ -277,7 +307,18 @@ export const useConnections = create((set, get) => ({
},
updateConnection: async (id, updates, password) => {
- const connections = get().connections.map((c) => (c.id === id ? { ...c, ...updates } : c))
+ const connections = get().connections.map((c) => {
+ if (c.id !== id) return c
+ const merged = { ...c, ...updates }
+ // A different URL is a different server: drop the recorded probe so the
+ // next client build re-detects the transport instead of reusing the
+ // previous server's answer.
+ if (updates.url !== undefined && updates.url !== c.url) {
+ merged.api = undefined
+ merged.serverVersion = undefined
+ }
+ return merged
+ })
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
@@ -294,29 +335,24 @@ export const useConnections = create((set, get) => ({
const active = connections.find((c) => c.id === id)!
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${id}`)
const auth = buildAuth(active.username, password)
- const built = buildClient(active.url, active.directory, auth)
- try {
- const [project, paths] = await Promise.all([
- built.client.project.current().catch(() => null),
- built.client.path.get().catch(() => null),
- ])
- set({
- connections,
- activeConnection: active,
- client: built.client,
- clientBase: built.base,
- currentProject: project,
- serverHome: paths?.home || null,
- })
- } catch {
- set({
- connections,
- activeConnection: active,
- client: built.client,
- clientBase: built.base,
- currentProject: null,
- })
- }
+ const { api, resolved } = await resolveApi(active, auth)
+ const built = buildClient(active.url, active.directory, auth, api)
+ if (resolved) await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
+ set({
+ connections,
+ activeConnection: active,
+ client: built.client,
+ clientBase: built.base,
+ currentProject: null,
+ serverHome: null,
+ })
+ void Promise.all([
+ built.client.project.current().catch(() => null),
+ built.client.path.get().catch(() => null),
+ ]).then(([project, paths]) => {
+ if (get().client !== built.client) return
+ set({ currentProject: project, serverHome: paths?.home || null })
+ })
} else {
set({ connections })
}
@@ -340,7 +376,7 @@ export const useConnections = create((set, get) => ({
// Reuse current client if directory matches
const active = get().activeConnection
if (active?.directory === directory) return get().client
- return createClient({ baseUrl: base.baseUrl, directory, auth: base.auth })
+ return createClient({ baseUrl: base.baseUrl, directory, auth: base.auth, api: base.api })
},
switchDirectory: async (directory) => {
diff --git a/src/stores/events.ts b/src/stores/events.ts
index c53a8481..b9939e84 100644
--- a/src/stores/events.ts
+++ b/src/stores/events.ts
@@ -2,13 +2,15 @@ import { create } from "zustand"
import { useConnections } from "./connections"
import { useSessions, abortedSessions } from "./sessions"
import { send as notify } from "../lib/notifications"
-import { sanitizeBody } from "../lib/notify-format"
+import { permissionNotificationBody, questionNotificationBody, sanitizeBody } from "../lib/notify-format"
import { statusFromPart } from "../lib/status-labels"
import { addBreadcrumb } from "../lib/sentry"
import { AnalyticsEvent, track } from "../lib/analytics"
import { recordSuccessfulSession } from "../lib/store-review"
import { isAuthError } from "../lib/api-error"
import { isSessionActuallyIdle } from "../lib/session-status-reconcile"
+import { sessionActivityFromMessages, shouldReconnectOnResume, shouldResetRetries, reconnectDelayMs, type TransportState } from "../lib/sse-lifecycle"
+import { mergePendingPermissions, normalizePendingPermission, normalizePermissionEvent, type PendingPermission } from "../lib/permissions"
import type { Client, Part, Session, Message } from "../lib/sdk"
// Session status from the server
@@ -16,6 +18,7 @@ type SessionStatus = { type: "idle" } | { type: "busy" } | { type: "retry"; atte
interface EventsState {
connected: boolean
+ transport: TransportState
// Set when the last connection attempt failed with 401/403 — the server
// rejected our credentials, not a transient network issue. The reconnect
// loop stops retrying in this case (see connect()) since hammering a
@@ -28,17 +31,7 @@ interface EventsState {
sessionStatus: Record
statusText: Record
// Permissions & questions (pending per session)
- permissions: Record<
- string,
- Array<{
- id: string
- sessionID: string
- permission: string
- patterns: string[]
- metadata: Record
- tool?: { messageID: string; callID: string }
- }>
- >
+ permissions: Record
questions: Record<
string,
Array<{
@@ -57,19 +50,25 @@ interface EventsState {
connect: () => void
disconnect: () => void
+ background: () => void
+ // Force an immediate reconnect + full resync, bypassing the backoff loop.
+ // Called when the app returns to the foreground: Android freezes JS timers
+ // and usually kills the SSE socket while backgrounded, so the backoff
+ // timers that were scheduled never ran and the stream is stale.
+ reconnectNow: () => void
}
let controller: AbortController | null = null
let reconnectTimer: ReturnType | null = null
+let appBackgrounded = false
// Sessions that emitted session.error since they last went busy. SessionStatus
// has no error variant — an errored session still ends with a busy -> idle
// transition — so without this mark an errored run would count as a success
// toward the once-ever store review prompt.
const erroredSessions = new Set()
+const sessionStatusRevision = new Map()
-const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 15000] as const
-const STABLE_CONNECTION_MS = 10_000
const PROLONGED_DISCONNECT_MS = 30_000
// Re-fetch pending permissions and questions from the server for a session.
@@ -77,11 +76,13 @@ const PROLONGED_DISCONNECT_MS = 30_000
// optimistic removals.
export async function refreshPending(client: Client, sessionID: string) {
try {
- const [perms, questions] = await Promise.all([client.permission.list(), client.question.list()])
- const sessionPerms = (perms || []).filter((p: Record) => p.sessionID === sessionID)
+ // v2 permission/form lists are session-scoped — pass the session so
+ // pending prompts are found regardless of the server's active location.
+ const [perms, questions] = await Promise.all([client.permission.list(sessionID), client.question.list(sessionID)])
+ const sessionPerms = (perms || []).map(normalizePendingPermission).filter((p): p is PendingPermission => !!p && p.sessionID === sessionID)
const sessionQuestions = (questions || []).filter((q: Record) => q.sessionID === sessionID)
useEvents.setState((state) => ({
- permissions: { ...state.permissions, [sessionID]: sessionPerms as any },
+ permissions: { ...state.permissions, [sessionID]: mergePendingPermissions([], sessionPerms) },
questions: { ...state.questions, [sessionID]: sessionQuestions as any },
}))
} catch (err) {
@@ -89,6 +90,97 @@ export async function refreshPending(client: Client, sessionID: string) {
}
}
+async function refreshAllPending() {
+ const connectionState = useConnections.getState()
+ const sessionsState = useSessions.getState()
+ const sessionsByID = new Map(sessionsState.sessions.map((session) => [session.id, session]))
+ if (sessionsState.currentSession) sessionsByID.set(sessionsState.currentSession.id, sessionsState.currentSession)
+ const sessionIDs = new Set([...sessionsByID.keys(), ...Object.keys(useEvents.getState().permissions)])
+ const groups = new Map }>()
+ const clientFor = (directory?: string) => directory
+ ? connectionState.clientForDirectory(directory) ?? connectionState.client
+ : connectionState.client
+
+ for (const sessionID of sessionIDs) {
+ const session = sessionsByID.get(sessionID)
+ const directory = session?.directory ?? connectionState.activeConnection?.directory
+ const key = directory ?? ""
+ let group = groups.get(key)
+ if (!group) {
+ const client = clientFor(directory)
+ if (!client) continue
+ group = { client, sessionIDs: new Set() }
+ groups.set(key, group)
+ }
+ group.sessionIDs.add(sessionID)
+ }
+ if (groups.size === 0 && connectionState.client) {
+ groups.set(connectionState.activeConnection?.directory ?? "", { client: connectionState.client, sessionIDs: new Set() })
+ }
+
+ await Promise.all([...groups.values()].map(async ({ client, sessionIDs: knownSessionIDs }) => {
+ try {
+ const [pending, questions] = await Promise.all([client.permission.list(), client.question.list()])
+ const permissionsBySession = new Map()
+ const questionsBySession = new Map>()
+ for (const value of pending || []) {
+ const normalized = normalizePendingPermission(value)
+ if (normalized) permissionsBySession.set(normalized.sessionID, [...(permissionsBySession.get(normalized.sessionID) || []), normalized])
+ }
+ for (const question of questions || []) {
+ if (question?.sessionID) questionsBySession.set(question.sessionID, [...(questionsBySession.get(question.sessionID) || []), question])
+ }
+ const previous = useEvents.getState()
+ for (const [sessionID, items] of permissionsBySession) {
+ const existingIDs = new Set((previous.permissions[sessionID] || []).map((item) => item.id))
+ for (const item of items) {
+ if (existingIDs.has(item.id)) continue
+ notify({
+ category: "permissions",
+ title: "Agent needs approval",
+ body: permissionNotificationBody(),
+ sessionId: item.sessionID,
+ dedupeKey: `perm-${item.id}`,
+ dedupeCooldownMs: 60_000,
+ })
+ existingIDs.add(item.id)
+ }
+ }
+ for (const [sessionID, items] of questionsBySession) {
+ const existingIDs = new Set((previous.questions[sessionID] || []).map((item) => item.id))
+ for (const item of items) {
+ if (existingIDs.has(item.id)) continue
+ notify({
+ category: "questions",
+ title: "Input needed",
+ body: questionNotificationBody(),
+ sessionId: sessionID,
+ dedupeKey: `question-${item.id}`,
+ dedupeCooldownMs: 60_000,
+ })
+ existingIDs.add(item.id)
+ }
+ }
+ useEvents.setState((state) => {
+ const permissions = { ...state.permissions }
+ const currentQuestions = { ...state.questions }
+ const affected = new Set([...knownSessionIDs, ...permissionsBySession.keys(), ...questionsBySession.keys()])
+ for (const sessionID of affected) {
+ const items = permissionsBySession.get(sessionID) || []
+ if (items.length) permissions[sessionID] = mergePendingPermissions([], items)
+ else delete permissions[sessionID]
+ const sessionQuestions = questionsBySession.get(sessionID) || []
+ if (sessionQuestions.length) currentQuestions[sessionID] = sessionQuestions as typeof currentQuestions[string]
+ else delete currentQuestions[sessionID]
+ }
+ return { permissions, questions: currentQuestions }
+ })
+ } catch (err) {
+ console.warn("[Events] Failed to reconcile pending permissions:", err)
+ }
+ }))
+}
+
// Re-sync any session currently marked "busy" against the server after an
// SSE reconnect. sessionStatus/sending are SSE-driven and there is normally
// no other path to idle — if the server's busy -> idle `session.status`
@@ -103,8 +195,9 @@ export async function refreshPending(client: Client, sessionID: string) {
// before writing, so a real session.status event that lands while the fetch
// is in flight (e.g. the session went busy again) wins over this resync.
async function resyncBusySessions() {
+ const currentSessionID = useSessions.getState().currentSession?.id
const busySessionIDs = Object.entries(useEvents.getState().sessionStatus)
- .filter(([, status]) => status.type === "busy")
+ .filter(([sessionID, status]) => status.type === "busy" && sessionID !== currentSessionID)
.map(([sessionID]) => sessionID)
if (busySessionIDs.length === 0) return
@@ -145,8 +238,45 @@ async function resyncBusySessions() {
)
}
+async function reconcileOpenSession() {
+ const session = useSessions.getState().currentSession
+ if (!session) return
+ const revision = sessionStatusRevision.get(session.id) ?? 0
+ const connectionState = useConnections.getState()
+ const client = session.directory
+ ? connectionState.clientForDirectory(session.directory) ?? connectionState.client
+ : connectionState.client
+ if (!client) return
+
+ const errorBefore = useSessions.getState().error
+ await useSessions.getState().refreshMessages()
+ if (useSessions.getState().currentSession?.id !== session.id) return
+ const state = useSessions.getState()
+ if (state.error && state.error !== errorBefore) return
+ const authoritativeStatus = await client.session.status().catch((err) => {
+ console.warn("[Events] Failed to reconcile authoritative session status:", err)
+ return null
+ })
+ if (useSessions.getState().currentSession?.id !== session.id) return
+
+ const fallbackActivity = sessionActivityFromMessages(state.messages)
+ const serverStatus = authoritativeStatus?.[session.id]
+ const activity = serverStatus?.type ?? (authoritativeStatus ? "idle" : fallbackActivity)
+ if (activity !== "unknown" && (sessionStatusRevision.get(session.id) ?? 0) === revision) {
+ const status: SessionStatus = serverStatus?.type === "retry" || activity === "retry"
+ ? { type: "retry", attempt: serverStatus?.attempt ?? 1, message: serverStatus?.message ?? "Retrying" }
+ : { type: activity }
+ useEvents.setState((current) => ({
+ sessionStatus: { ...current.sessionStatus, [session.id]: status },
+ statusText: activity === "idle" ? { ...current.statusText, [session.id]: "" } : current.statusText,
+ }))
+ useSessions.setState((current) => ({ sending: { ...current.sending, [session.id]: activity !== "idle" } }))
+ }
+}
+
export const useEvents = create((set, get) => ({
connected: false,
+ transport: "idle",
authError: false,
reconnectAttempts: 0,
lastDisconnectAt: null,
@@ -168,7 +298,7 @@ export const useEvents = create((set, get) => ({
controller = new AbortController()
const currentController = controller
- set({ connected: true, authError: false })
+ set({ connected: false, transport: get().reconnectAttempts > 0 ? "reconnecting" : "connecting", authError: false })
console.log("[SSE] Connecting to event stream...")
addBreadcrumb({ category: "sse", message: "connecting" })
@@ -181,11 +311,7 @@ export const useEvents = create((set, get) => ({
// failed retries can't re-arm the check on every attempt.
const isReconnect = get().reconnectAttempts > 0
let resyncedAfterReconnect = false
- const stableTimer = setTimeout(() => {
- if (!currentController.signal.aborted) {
- set({ reconnectAttempts: 0, lastDisconnectAt: null })
- }
- }, STABLE_CONNECTION_MS)
+ let receivedData = false
const scheduleReconnect = (reason: unknown) => {
if (reconnectScheduled || currentController.signal.aborted) return
@@ -194,7 +320,7 @@ export const useEvents = create((set, get) => ({
const reconnectAttempts = state.reconnectAttempts + 1
const lastDisconnectAt = state.lastDisconnectAt ?? Date.now()
const disconnectedFor = Date.now() - lastDisconnectAt
- set({ connected: false, reconnectAttempts, lastDisconnectAt })
+ set({ connected: false, transport: "reconnecting", reconnectAttempts, lastDisconnectAt })
if (disconnectedFor >= PROLONGED_DISCONNECT_MS) {
notify({
@@ -207,8 +333,7 @@ export const useEvents = create((set, get) => ({
})
}
- const baseDelay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempts - 1, RECONNECT_DELAYS_MS.length - 1)]
- const jitteredDelay = Math.min(15_000, Math.round(baseDelay * (0.75 + Math.random() * 0.5)))
+ const jitteredDelay = reconnectDelayMs(reconnectAttempts)
console.warn(`[SSE] Connection lost, reconnecting in ${jitteredDelay}ms:`, reason)
addBreadcrumb({
category: "sse",
@@ -226,12 +351,21 @@ export const useEvents = create((set, get) => ({
for await (const event of client.global.events(currentController.signal)) {
if (currentController.signal.aborted) break
+ if (!receivedData) {
+ receivedData = true
+ if (shouldResetRetries(true)) {
+ set({ connected: true, transport: "live", reconnectAttempts: 0, lastDisconnectAt: null })
+ }
+ }
+
// The stream is genuinely live again (we're actually receiving
// data, not just optimistically marked "connected") — resync once
// per reconnect, not on every event.
if (isReconnect && !resyncedAfterReconnect) {
resyncedAfterReconnect = true
void resyncBusySessions()
+ void refreshAllPending()
+ void reconcileOpenSession()
}
const payload = (event as any).payload || event
@@ -243,6 +377,7 @@ export const useEvents = create((set, get) => ({
const sessionID = props.sessionID as string
const status = props.status as SessionStatus
if (!sessionID) break
+ sessionStatusRevision.set(sessionID, (sessionStatusRevision.get(sessionID) ?? 0) + 1)
// Detect busy → idle transition for completion notification
const previous = get().sessionStatus[sessionID]
@@ -370,28 +505,22 @@ export const useEvents = create((set, get) => ({
break
}
- case "permission.asked": {
- const req = props as any
- if (!req.id || !req.sessionID) break
+ case "permission.asked":
+ case "permission.v2.asked": {
+ const req = normalizePermissionEvent({ type, properties: props })
+ if (!req) break
const existing = get().permissions[req.sessionID] || []
if (existing.some((item) => item.id === req.id)) break
set((state) => ({
permissions: {
...state.permissions,
- [req.sessionID]: [...(state.permissions[req.sessionID] || []), req],
+ [req.sessionID]: mergePendingPermissions(state.permissions[req.sessionID] || [], [req]),
},
}))
notify({
category: "permissions",
title: "Agent needs approval",
- body: sanitizeBody(
- req.permission
- ? req.patterns?.length
- ? `${req.permission}: ${req.patterns.join(", ")}`
- : req.permission
- : req.patterns?.join(", "),
- "A tool needs your approval",
- ),
+ body: permissionNotificationBody(),
sessionId: req.sessionID,
dedupeKey: `perm-${req.id}`,
dedupeCooldownMs: 60_000,
@@ -412,6 +541,19 @@ export const useEvents = create((set, get) => ({
break
}
+ case "permission.v2.replied": {
+ const sessionID = props.sessionID as string
+ const requestID = props.requestID as string
+ if (!sessionID || !requestID) break
+ set((state) => ({
+ permissions: {
+ ...state.permissions,
+ [sessionID]: (state.permissions[sessionID] || []).filter((p) => p.id !== requestID),
+ },
+ }))
+ break
+ }
+
case "question.asked": {
const req = props as any
if (!req.id || !req.sessionID) break
@@ -425,8 +567,8 @@ export const useEvents = create((set, get) => ({
}))
notify({
category: "questions",
- title: req.questions?.[0]?.header || "Input needed",
- body: sanitizeBody(req.questions?.[0]?.question, "The assistant has a question"),
+ title: "Input needed",
+ body: questionNotificationBody(),
sessionId: req.sessionID,
dedupeKey: `question-${req.id}`,
dedupeCooldownMs: 60_000,
@@ -466,12 +608,11 @@ export const useEvents = create((set, get) => ({
data: { status: err.status },
})
track(AnalyticsEvent.ConnectionFailed, { source: "sse", error_class: "unauthorized" })
- set({ connected: false, authError: true })
+ set({ connected: false, transport: "auth-required", authError: true })
} else {
scheduleReconnect(err)
}
} finally {
- clearTimeout(stableTimer)
if (currentController.signal.aborted) {
console.log("[SSE] Disconnected (aborted)")
}
@@ -479,6 +620,37 @@ export const useEvents = create((set, get) => ({
})()
},
+ background: () => {
+ if (!useConnections.getState().client) return
+ appBackgrounded = true
+ // Keep the server work and existing reader alive if Android allows it, but
+ // don't advertise a verified live stream while app timers/network may be
+ // suspended. Foreground recovery will replace the reader and reconcile.
+ set({ connected: false, transport: "backgrounded" })
+ },
+
+ reconnectNow: () => {
+ const connState = useConnections.getState()
+ if (!connState.client) return
+ const state = get()
+ const forceBackgroundResume = appBackgrounded || state.transport === "backgrounded"
+ const attemptInFlight = !forceBackgroundResume &&
+ (reconnectTimer !== null || (controller !== null && !controller.signal.aborted))
+ if (state.authError || !shouldReconnectOnResume({ transport: state.transport, attemptInFlight })) return
+ appBackgrounded = false
+ console.log("[SSE] Foreground reconnect — re-establishing event stream")
+ if (reconnectTimer) {
+ clearTimeout(reconnectTimer)
+ reconnectTimer = null
+ }
+ controller?.abort()
+ controller = null
+ // Ensure connect() treats this as a reconnect even if the previous stream
+ // had been marked stable before Android suspended its timers/socket.
+ set({ connected: false, reconnectAttempts: Math.max(1, state.reconnectAttempts), lastDisconnectAt: state.lastDisconnectAt ?? Date.now() })
+ get().connect()
+ },
+
disconnect: () => {
console.log("[SSE] Disconnecting")
addBreadcrumb({ category: "sse", message: "disconnected" })
@@ -488,10 +660,13 @@ export const useEvents = create((set, get) => ({
}
controller?.abort()
controller = null
+ appBackgrounded = false
erroredSessions.clear()
abortedSessions.clear()
+ sessionStatusRevision.clear()
set({
connected: false,
+ transport: "idle",
authError: false,
reconnectAttempts: 0,
lastDisconnectAt: null,
diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts
index ccd21f33..86268a91 100644
--- a/src/stores/sessions.ts
+++ b/src/stores/sessions.ts
@@ -1,4 +1,5 @@
import { create } from "zustand"
+import * as SecureStore from "expo-secure-store"
import { ApiError, type Session, type Message, type Part, type Event, type MessageWithParts, type Client } from "../lib/sdk"
import { useConnections } from "./connections"
import { useSettings } from "./settings"
@@ -7,6 +8,8 @@ import { AnalyticsEvent, track } from "../lib/analytics"
import { extractPromptFromParts, type PromptFromParts } from "../lib/prompt-from-parts"
import { mergeIncomingMessage } from "../lib/message-merge"
import { isColdSessionLoad, isLiveEventForSession } from "../lib/session-load-reconcile"
+import { buildPromptParts } from "../lib/composer-prompt"
+import { readSessionDraft, writeSessionDraft } from "../lib/session-drafts"
// Helper to convert API response to our internal format
function parseMessages(response: MessageWithParts[]): { messages: Message[]; parts: Record } {
@@ -33,6 +36,8 @@ interface SessionsState {
isLoading: boolean
// Per-session optimistic sending flag — bridging gap between user tap and SSE busy
sending: Record
+ /** Encrypted, per-session user-entered drafts; never contains server output. */
+ drafts: Record
loadingMore: boolean
hasMore: boolean
error: string | null
@@ -40,6 +45,11 @@ interface SessionsState {
// Actions
loadSessions: () => Promise
selectSession: (sessionID: string, directory?: string) => Promise
+ loadLastSession: () => Promise
+ loadDraft: (sessionID: string) => Promise
+ setDraft: (sessionID: string, text: string) => void
+ clearDraft: (sessionID: string) => void
+ flushDrafts: () => Promise
loadOlderMessages: () => Promise
createSession: (title?: string) => Promise
deleteSession: (sessionID: string) => Promise
@@ -71,11 +81,34 @@ export type RevertResult = ({ ok: true } & PromptFromParts) | { ok: false; reaso
// this module) clears entries on busy and checks them on busy -> idle.
export const abortedSessions = new Set()
+const DRAFT_SAVE_DELAY_MS = 350
+export const LAST_SESSION_KEY = "opencode.session.last.v1"
+export type LastSessionReference = { sessionID: string; directory?: string; connectionID?: string }
+const loadedDrafts = new Set()
+const queuedDraftWrites = new Map()
+let draftSaveTimer: ReturnType | null = null
+let draftWriteQueue: Promise = Promise.resolve()
+
+function queueDraftWrite(sessionID: string, text: string) {
+ queuedDraftWrites.set(sessionID, text)
+ if (draftSaveTimer) clearTimeout(draftSaveTimer)
+ draftSaveTimer = setTimeout(() => {
+ draftSaveTimer = null
+ void useSessions.getState().flushDrafts().catch((err) => console.warn("[Sessions] Failed to persist drafts:", err))
+ }, DRAFT_SAVE_DELAY_MS)
+}
+
// Monotonic token guarding selectSession against out-of-order resolution: a
// slow fetch for a session the user has already navigated away from must not
// overwrite the messages/currentSession of a newer selection. Each call takes
// the next value and only commits its result if still the latest.
let selectSeq = 0
+const refreshSeq = new Map()
+const liveRefreshes = new Map }>()
+
+function noteLiveMessage(sessionID: string, messageID: string) {
+ liveRefreshes.get(sessionID)?.updatedMessageIDs.add(messageID)
+}
// Get the right client for a session's directory
function clientFor(directory?: string): Client | null {
@@ -93,10 +126,90 @@ export const useSessions = create((set, get) => ({
parts: {},
isLoading: false,
sending: {},
+ drafts: {},
loadingMore: false,
hasMore: false,
error: null,
+ loadLastSession: async () => {
+ try {
+ const saved = await SecureStore.getItemAsync(LAST_SESSION_KEY)
+ if (!saved) return null
+ const value = JSON.parse(saved) as Partial
+ if (typeof value.sessionID !== "string" || !value.sessionID) return null
+ return {
+ sessionID: value.sessionID,
+ ...(typeof value.directory === "string" ? { directory: value.directory } : {}),
+ ...(typeof value.connectionID === "string" ? { connectionID: value.connectionID } : {}),
+ }
+ } catch (err) {
+ console.warn("[Sessions] Failed to restore selected session reference:", err)
+ return null
+ }
+ },
+
+ loadDraft: async (sessionID) => {
+ if (loadedDrafts.has(sessionID)) return get().drafts[sessionID] ?? ""
+ try {
+ const saved = await readSessionDraft(SecureStore, sessionID)
+ // A keystroke/clear may happen while the secure-store read is in flight;
+ // setDraft/clearDraft marks the key loaded so this stale read cannot win.
+ if (!loadedDrafts.has(sessionID)) {
+ loadedDrafts.add(sessionID)
+ if (saved !== null) {
+ set((state) => state.drafts[sessionID] === undefined
+ ? { drafts: { ...state.drafts, [sessionID]: saved } }
+ : state)
+ }
+ }
+ } catch (err) {
+ loadedDrafts.add(sessionID)
+ console.warn("[Sessions] Failed to load encrypted draft:", err)
+ }
+ return get().drafts[sessionID] ?? ""
+ },
+
+ setDraft: (sessionID, text) => {
+ if (text.length === 0) {
+ get().clearDraft(sessionID)
+ return
+ }
+ loadedDrafts.add(sessionID)
+ set((state) => ({ drafts: { ...state.drafts, [sessionID]: text } }))
+ queueDraftWrite(sessionID, text)
+ },
+
+ clearDraft: (sessionID) => {
+ loadedDrafts.add(sessionID)
+ set((state) => {
+ if (!(sessionID in state.drafts)) return state
+ const drafts = { ...state.drafts }
+ delete drafts[sessionID]
+ return { drafts }
+ })
+ queueDraftWrite(sessionID, "")
+ // Clear-after-ack must hit encrypted storage immediately; otherwise a
+ // process death inside the debounce window could restore an already-sent
+ // prompt and invite an accidental duplicate submission.
+ void get().flushDrafts().catch((err) => console.warn("[Sessions] Failed to clear encrypted draft:", err))
+ },
+
+ flushDrafts: async () => {
+ if (draftSaveTimer) {
+ clearTimeout(draftSaveTimer)
+ draftSaveTimer = null
+ }
+ if (queuedDraftWrites.size === 0) return draftWriteQueue
+ const batch = [...queuedDraftWrites]
+ queuedDraftWrites.clear()
+ draftWriteQueue = draftWriteQueue.catch(() => {}).then(async () => {
+ for (const [sessionID, text] of batch) {
+ await writeSessionDraft(SecureStore, sessionID, text)
+ }
+ })
+ return draftWriteQueue
+ },
+
loadSessions: async () => {
const connState = useConnections.getState()
// Use a directory-less client so the server returns sessions from ALL projects,
@@ -170,6 +283,11 @@ export const useSessions = create((set, get) => ({
// If we got exactly PAGE_SIZE messages, there are probably more
hasMore: messagesResponse.length >= pageSize(),
})
+ void SecureStore.setItemAsync(LAST_SESSION_KEY, JSON.stringify({
+ sessionID: session.id,
+ directory: session.directory || directory,
+ connectionID: connState.activeConnection?.id,
+ })).catch((err) => console.warn("[Sessions] Failed to save selected session:", err))
} catch (err) {
if (seq !== selectSeq) return
console.error("Failed to load session:", err)
@@ -226,6 +344,11 @@ export const useSessions = create((set, get) => ({
hasMore: false,
loadingMore: false,
})
+ void SecureStore.setItemAsync(LAST_SESSION_KEY, JSON.stringify({
+ sessionID: created.id,
+ directory: created.directory,
+ connectionID: connState.activeConnection?.id,
+ })).catch((err) => console.warn("[Sessions] Failed to save selected session:", err))
return created
} catch (error) {
set({ error: "Failed to create session" })
@@ -243,6 +366,12 @@ export const useSessions = create((set, get) => ({
try {
await client.session.delete(sessionID)
+ get().clearDraft(sessionID)
+ void SecureStore.getItemAsync(LAST_SESSION_KEY).then((saved) => {
+ if ((JSON.parse(saved || "null") as LastSessionReference | null)?.sessionID === sessionID) {
+ return SecureStore.deleteItemAsync(LAST_SESSION_KEY)
+ }
+ }).catch((err) => console.warn("[Sessions] Failed to clear deleted session reference:", err))
set((state) => ({
sessions: state.sessions.filter((s) => s.id !== sessionID),
currentSession: state.currentSession?.id === sessionID ? null : state.currentSession,
@@ -304,19 +433,9 @@ export const useSessions = create((set, get) => ({
parts: { ...state.parts, [userMessage.id]: optimisticParts },
}))
- // Build prompt parts - images are already converted to JPEG with base64 by toJpeg()
- const promptParts: Array<
- { type: "text"; text: string } | { type: "file"; mime: string; url: string; filename?: string }
- > = []
- if (text) {
- promptParts.push({ type: "text", text })
- }
- if (files) {
- for (const f of files) {
- const url = f.base64 ? `data:${f.mime};base64,${f.base64}` : f.uri
- promptParts.push({ type: "file", mime: f.mime, url, filename: f.filename })
- }
- }
+ // Build prompt parts without altering text. Images are already converted
+ // to JPEG with base64 by toJpeg().
+ const promptParts = buildPromptParts(text, files)
// Await submission (POST to /prompt_async resolves fast, well before the
// streamed response) so a failure here can propagate to the caller — SSE
@@ -354,13 +473,35 @@ export const useSessions = create((set, get) => ({
const client = clientFor(get().currentSession?.directory)
const session = get().currentSession
if (!client || !session) return
+ const seq = (refreshSeq.get(session.id) ?? 0) + 1
+ refreshSeq.set(session.id, seq)
+ const liveRefresh = { seq, updatedMessageIDs: new Set() }
+ liveRefreshes.set(session.id, liveRefresh)
try {
const response = await client.session.messages(session.id)
- const { messages, parts } = parseMessages(response)
- set({ messages, parts })
+ if (get().currentSession?.id !== session.id || refreshSeq.get(session.id) !== seq) return
+ const snapshot = parseMessages(response)
+ const current = get()
+ const serverMessages = new Map(snapshot.messages.map((message) => [message.id, message]))
+ const currentParts = { ...snapshot.parts }
+
+ // SSE can update a message while this GET is in flight. Preserve only
+ // those observed-live message rows/parts over the fetched snapshot, while
+ // still incorporating all server rows missed during the disconnect.
+ for (const message of current.messages) {
+ if (message.id.startsWith("temp-") || liveRefresh.updatedMessageIDs.has(message.id)) {
+ serverMessages.set(message.id, message)
+ if (current.parts[message.id]) currentParts[message.id] = current.parts[message.id]
+ }
+ }
+ const messages = [...serverMessages.values()].sort((a, b) => a.time.created - b.time.created)
+ set({ messages, parts: currentParts })
} catch (error) {
+ if (get().currentSession?.id !== session.id || refreshSeq.get(session.id) !== seq) return
set({ error: "Failed to refresh messages" })
+ } finally {
+ if (liveRefreshes.get(session.id) === liveRefresh) liveRefreshes.delete(session.id)
}
},
@@ -422,6 +563,7 @@ export const useSessions = create((set, get) => ({
case "message.updated": {
const message = (props.info || props.message) as Message | undefined
if (!message || !isLiveEventForSession(message.sessionID, currentSession.id)) return
+ noteLiveMessage(message.sessionID, message.id)
set((state) => ({
messages: mergeIncomingMessage(state.messages, message),
@@ -439,6 +581,7 @@ export const useSessions = create((set, get) => ({
if (!part) return
// Only handle parts for current session
if (part.sessionID && part.sessionID !== currentSession.id) return
+ noteLiveMessage(currentSession.id, part.messageID)
set((state) => {
const messageParts = state.parts[part.messageID] || []