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() { @@ -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] || []