From bb395bef4148a8b1347bcdec849e74b294665ae8 Mon Sep 17 00:00:00 2001 From: bluetoe-99 <102478736+bluetoe-99@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:20:53 -0400 Subject: [PATCH] feat: opencode v2 server support with v1 compatibility opencode 2.x replaced the HTTP API: routes moved under /api/, the SSE envelope changed from {"payload":{id,type,properties}} to {id,type,data}, message/part payloads were renamed (Session.Info, Message.Info, UserMessage/AssistantMessage), and question prompts became server-side forms. `npm install -g opencode-ai` still installs the 1.x line (1.18.32 as of 2026-09-24), so a client has to speak both APIs. v2 client (src/lib/sdk.ts, src/lib/v2-shape.ts): - /api/... endpoints, the v2 message shapes, interrupt, staged revert, session diff - SSE via /api/event; the question tool arrives as a form nested under data.form on form.created - reading it flat silently dropped every prompt - session-scoped permission and form replies v1 client (src/lib/sdk-v1.ts, src/lib/api-detect.ts): - the transport main already ships, restored unchanged (sessions, prompts, abort, revert, diff, questions, permissions, agents, commands, providers, file browsing) - a connect-time probe classifies the server by response content type: unknown routes on both server generations answer 200 text/html through the web SPA, so status codes cannot discriminate. /api/info JSON means v2, /global/health JSON means v1; api + serverVersion are recorded on the connection and createClient() dispatches on them Message UI: - selection and copying rework around MessageTextSheet: Select text / Edit are header buttons and the sheet renders the whole message (text, reasoning, tool input/result/error, files) in a plain ScrollView where native selection works, with Copy all as the whole-message fallback. The bubble has no press at all: a press whose movement stays inside its bounds is kept by the touchable, which opened the message menu mid-scroll. - inline selection is iOS-only for user message text, message error text, code lines and diff lines: selectable inside FlatList rows is the Android bug (react-native#46999, still open) that Markdown.tsx already works around. ToolCallCard, ReasoningBlock, ErrorBoundary and Markdown.tsx are unchanged. - wide code and diff lines wrap instead of scrolling sideways; CodeBlock's Copy still hands the clipboard the original string, so pasted code is byte-identical - keyboard avoidance via react-native-keyboard-controller, including system-bar insets, so the composer is never hidden behind the keyboard; SSE reconnects on app foreground Verified against live servers: opencode 2.0.16 (v2 endpoints, and the question path replayed from a captured form.created stream) and opencode 1.18.32 (`opencode serve`: health, project, path, session create/list/update/delete/abort/messages, agents, commands, questions, permissions, providers, and the wrapped {"payload":...} event stream through the existing unwrap in stores/events.ts). docs/v2-api-map.md records the verified API maps, event schemas and probe behaviour for both server generations. Signed-off-by: bluetoe-99 <102478736+bluetoe-99@users.noreply.github.com> --- app/_layout.tsx | 22 + app/connection/add.tsx | 6 + app/session/[id].tsx | 164 ++-- docs/v2-api-map.md | 122 +++ package-lock.json | 15 + package.json | 1 + src/components/chat/DiffView.tsx | 66 +- src/components/chat/MessageBubble.tsx | 111 ++- src/components/chat/MessageTextSheet.tsx | 79 ++ src/components/chat/index.ts | 1 + src/components/markdown/CodeBlock.tsx | 29 +- ...essage-bubble-selection.regression.test.ts | 94 ++ .../wide-content-scroll.regression.test.ts | 45 - .../wide-content-wrap.regression.test.ts | 82 ++ src/lib/api-detect.test.ts | 81 ++ src/lib/api-detect.ts | 80 ++ src/lib/i18n/en.json | 17 +- src/lib/i18n/zh-Hans.json | 17 +- src/lib/message-text.test.ts | 76 ++ src/lib/message-text.ts | 76 ++ src/lib/scroll-config.test.ts | 38 +- src/lib/scroll-config.ts | 45 +- src/lib/sdk-v1.ts | 545 ++++++++++++ src/lib/sdk.ts | 536 +++++++++--- src/lib/types.ts | 8 + src/lib/v2-shape.test.ts | 359 ++++++++ src/lib/v2-shape.ts | 820 ++++++++++++++++++ src/stores/connections.ts | 76 +- src/stores/events.ts | 37 +- 29 files changed, 3308 insertions(+), 340 deletions(-) create mode 100644 docs/v2-api-map.md create mode 100644 src/components/chat/MessageTextSheet.tsx create mode 100644 src/components/message-bubble-selection.regression.test.ts delete mode 100644 src/components/wide-content-scroll.regression.test.ts create mode 100644 src/components/wide-content-wrap.regression.test.ts create mode 100644 src/lib/api-detect.test.ts create mode 100644 src/lib/api-detect.ts create mode 100644 src/lib/message-text.test.ts create mode 100644 src/lib/message-text.ts create mode 100644 src/lib/sdk-v1.ts create mode 100644 src/lib/v2-shape.test.ts create mode 100644 src/lib/v2-shape.ts diff --git a/app/_layout.tsx b/app/_layout.tsx index c669aac9..e2f31efd 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -4,6 +4,7 @@ import { StatusBar } from "expo-status-bar" import { useColorScheme, View, ActivityIndicator, AppState } from "react-native" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { GestureHandlerRootView } from "react-native-gesture-handler" +import { KeyboardProvider } from "react-native-keyboard-controller" import { BottomSheetModalProvider } from "@gorhom/bottom-sheet" import { I18nextProvider, useTranslation } from "react-i18next" import i18n from "../src/lib/i18n/config" @@ -120,6 +121,22 @@ function RootLayout() { return () => sub.remove() }, []) + // Android freezes JS timers and usually drops the SSE socket while the app + // sits in the background (Doze/app standby), and the server may close idle + // streams too. On return to foreground, re-establish the stream immediately + // instead of waiting out the reconnect backoff (whose timers were frozen), + // and resync messages/permissions missed while away. reconnectNow() is + // idempotent, so an "active" fired by a transient "inactive" (biometric + // prompt, app switcher) is harmless. + useEffect(() => { + const sub = AppState.addEventListener("change", (next) => { + if (next === "active" && sseStarted.current) { + useEvents.getState().reconnectNow() + } + }) + return () => sub.remove() + }, []) + // Connect/disconnect SSE and load catalog when client changes useEffect(() => { if (client && !sseStarted.current) { @@ -169,6 +186,10 @@ function RootLayout() { return ( + {/* react-native-keyboard-controller: drives keyboard avoidance from + native WindowInsets instead of the legacy RN Keyboard events, which + misreport under Android's mandatory edge-to-edge mode (Expo SDK 54). */} + @@ -226,6 +247,7 @@ function RootLayout() { }} /> + ) } diff --git a/app/connection/add.tsx b/app/connection/add.tsx index b8616086..2f837e03 100644 --- a/app/connection/add.tsx +++ b/app/connection/add.tsx @@ -133,6 +133,10 @@ export default function AddConnectionScreen() { name: name.trim() || t("connection.shared.namePlaceholder"), type: "local", url: serverUrl, + // Carry the transport the pre-flight probe already determined, so the + // save does not probe the same server a second time. + api: result.api, + serverVersion: result.serverVersion, }, password || undefined, ) @@ -209,6 +213,8 @@ export default function AddConnectionScreen() { url: url.trim(), directory: directory.trim() || undefined, username: username.trim() || undefined, + api: result.api, + serverVersion: result.serverVersion, }, password || undefined, ) diff --git a/app/session/[id].tsx b/app/session/[id].tsx index 97f885f0..646c55f0 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -7,7 +7,6 @@ import { TouchableOpacity, StyleSheet, useColorScheme, - KeyboardAvoidingView, Platform, ActivityIndicator, Alert, @@ -20,6 +19,12 @@ import * as ImagePicker from "expo-image-picker" import * as ImageManipulator from "expo-image-manipulator" import * as Clipboard from "expo-clipboard" import type BottomSheet from "@gorhom/bottom-sheet" +// KeyboardAvoidingView from react-native-keyboard-controller (not react-native +// core): core KAV relies on legacy Keyboard events whose metrics misreport +// under Android's mandatory edge-to-edge mode (Expo SDK 54 / RN 0.81), leaving +// the composer hidden behind the keyboard. This library drives avoidance from +// native WindowInsets and is the standard fix for that setup. +import { KeyboardAvoidingView } from "react-native-keyboard-controller" import { MessageBubble, PermissionPrompt, @@ -30,6 +35,7 @@ import { VariantPicker, ImageAttachments, SessionInfo, + MessageTextSheet, type SlashCommand, type Attachment, } from "../../src/components/chat" @@ -39,6 +45,7 @@ import { useConnections } from "../../src/stores/connections" import { useAuth } from "../../src/stores/auth" import { useCatalog } from "../../src/stores/catalog" import { useSpeech } from "../../src/lib/speech" +import { messagePartsToSelectableText } from "../../src/lib/message-text" // --- Builtin slash commands --- const BUILTIN_COMMANDS: SlashCommand[] = [ @@ -85,6 +92,8 @@ export default function SessionScreen() { const [input, setInput] = useState("") const [attachments, setAttachments] = useState([]) const [showInfo, setShowInfo] = useState(false) + // Raw text of the message opened in the full-screen selection sheet + const [textSheet, setTextSheet] = useState(null) const { currentSession, @@ -93,6 +102,7 @@ export default function SessionScreen() { isLoading, loadingMore, hasMore, + error, selectSession, sendMessage, abortSession, @@ -190,10 +200,9 @@ export default function SessionScreen() { [messages, parts, revertMessageID], ) - // Tracks the latest composer text without pulling `input` into - // handleMessageLongPress's deps — kept as a plain ref assignment (not - // state) so the callback below stays referentially stable across - // keystrokes for MessageBubble's custom memo comparator. + // Tracks the latest composer text without pulling `input` into startEditFlow's + // deps — kept as a plain ref assignment (not state) so the callback stays + // referentially stable across keystrokes for MessageBubble's memo comparator. const inputRef = useRef(input) inputRef.current = input @@ -218,38 +227,62 @@ export default function SessionScreen() { ) }, [t]) - // Stable across renders (reads fresh state via getState() rather than - // closing over props) so MessageBubble's custom memo comparator can bail - // safely without risking a stale handler. - const handleMessageLongPress = useCallback((messageID: string) => { - Alert.alert(t("session.alerts.messageActionsTitle"), undefined, [ - { text: t("common.cancel"), style: "cancel" }, - { - text: t("session.actions.editMessage"), - onPress: () => { - const doRevert = async () => { - const result = await useSessions.getState().revertToMessage(messageID) - applyRevertResult(result) - } - // Editing overwrites the composer — don't silently clobber an - // in-progress unsent draft. - if (inputRef.current.trim()) { - Alert.alert( - t("session.alerts.replaceDraftTitle"), - t("session.alerts.replaceDraftMessage"), - [ - { text: t("common.cancel"), style: "cancel" }, - { text: t("session.actions.replace"), style: "destructive", onPress: doRevert }, - ], - { cancelable: false }, - ) - return - } - doRevert() - }, - }, - ]) - }, [applyRevertResult, t]) + // Full-screen selectable view: native text selection can't be trusted inside + // FlatList rows on Android (react-native#46999), so selection happens in a + // plain ScrollView outside the list. Reached from the assistant bubble's + // "Select text" button (a tap — assistant bubbles have no long-press) and + // from the user bubble's long-press action sheet. Covers the whole message: + // assistant text, reasoning and tool calls (name/input/result/error), not + // just text parts. Stable so MessageBubble's memo comparator can bail early. + const openSelectTextSheet = useCallback( + (messageID: string) => { + const messageParts = useSessions.getState().parts[messageID] || [] + setTextSheet( + messagePartsToSelectableText(messageParts, { + thinking: t("session.sheet.thinking"), + tool: t("session.sheet.tool"), + input: t("session.sheet.input"), + result: t("session.sheet.result"), + error: t("session.sheet.error"), + file: t("session.sheet.file"), + }), + ) + }, + [t], + ) + + // Editing a user message reverts the session to it and loads it into the + // composer. Reached from the user bubble's "Edit" button and from its + // long-press action sheet. Stable so MessageBubble's memo comparator bails. + const startEditFlow = useCallback( + (messageID: string) => { + const doRevert = async () => { + const result = await useSessions.getState().revertToMessage(messageID) + applyRevertResult(result) + } + // Editing overwrites the composer — don't silently clobber an + // in-progress unsent draft. + if (inputRef.current.trim()) { + Alert.alert( + t("session.alerts.replaceDraftTitle"), + t("session.alerts.replaceDraftMessage"), + [ + { text: t("common.cancel"), style: "cancel" }, + { text: t("session.actions.replace"), style: "destructive", onPress: doRevert }, + ], + { cancelable: false }, + ) + return + } + doRevert() + }, + [applyRevertResult, t], + ) + + // Message actions live in the bubble header as buttons (Select text / Edit), + // so there is no long-press action sheet here any more — holding a message + // must not pop a menu, because a long-press on the bubble competed with + // horizontal scrolling in code blocks and with text selection. const scrollToBottom = useCallback((animated = true) => { flatListRef.current?.scrollToOffset({ offset: 0, animated }) @@ -593,19 +626,21 @@ export default function SessionScreen() { {message.model && {message.model.modelID}} {!isUser && message.modelID && {message.modelID}} + {/* Message actions are taps in a right-aligned group at the end of the + header row — "Select text" sits immediately left of "Edit". Nothing + here is a bubble-wide gesture: a long-press on the bubble competed + with horizontal scrolling in code blocks and with text selection. + Labels are explicitly non-selectable so a tap can't be swallowed. */} + {(onSelectText || onEdit) && ( + + {onSelectText && hasSelectableContent(parts) && ( + onSelectText(message.id)} + testID={`message-select-text-${message.id}`} + > + + Select text + + + )} + {isUser && onEdit && ( + onEdit(message.id)} + testID={`message-edit-${message.id}`} + > + + Edit + + + )} + + )} {/* Image attachments */} @@ -87,7 +122,10 @@ export const MessageBubble = memo( {/* Message text */} {text.length > 0 && (isUser ? ( - + // Android: inline selection in a list row is unreliable (RN#46999) + // and competes with the bubble's long-press, so user text selects + // through the "Select text" sheet. iOS has no such conflict. + {text} ) : ( @@ -101,6 +139,18 @@ export const MessageBubble = memo( ))} + {/* Run-level failure (assistant message carries `error` when the agent + errored out — provider failure, aborted stream, etc.). Without this + an errored run looked identical to a successful one when reading + history; the live case is covered by the session error banner. */} + {message.error?.message && ( + + + {message.error.message} + + + )} + {/* Tokens/cost for assistant messages */} {!isUser && message.tokens && ( @@ -108,7 +158,7 @@ export const MessageBubble = memo( {message.cost ? ` · $${message.cost.toFixed(4)}` : ""} )} - + ) }, (prev, next) => { @@ -120,7 +170,8 @@ export const MessageBubble = memo( // unchanged (completed) messages during other messages' streaming. if (prev.message !== next.message) return false if (prev.isDark !== next.isDark) return false - if (prev.onLongPress !== next.onLongPress) return false + if (prev.onSelectText !== next.onSelectText) return false + if (prev.onEdit !== next.onEdit) return false if (prev.parts.length !== next.parts.length) return false for (let i = 0; i < prev.parts.length; i++) { if (prev.parts[i] !== next.parts[i]) return false @@ -141,6 +192,27 @@ const s = StyleSheet.create({ roleUser: { color: "#0a0a0a" }, textWhite: { color: "#ffffff" }, + actions: { + // Pushes the group to the right edge of the header row; the chips inside it + // stay adjacent in source order (Select text, then Edit). + marginLeft: "auto", + flexDirection: "row", + alignItems: "center", + gap: 6, + flexShrink: 0, + }, + selectChip: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 10, + borderWidth: 1, + borderColor: "#d4d4d4", + flexShrink: 0, + }, + selectChipDark: { borderColor: "#404040" }, + selectChipText: { fontSize: 11, color: "#525252" }, + selectChipTextDark: { color: "#d4d4d4" }, + modelTag: { fontSize: 11, color: "#999999", @@ -158,6 +230,19 @@ const s = StyleSheet.create({ tokens: { fontSize: 11, color: "#999999", marginTop: 8 }, tokensDark: { color: "#666666" }, + // Run-level error block inside an assistant bubble + errorBlock: { + marginTop: 8, + padding: 10, + borderRadius: 8, + backgroundColor: "#fef2f2", + borderWidth: 1, + borderColor: "#fecaca", + }, + errorBlockDark: { backgroundColor: "#3b1212", borderColor: "#7f1d1d" }, + errorBlockText: { color: "#b91c1c", fontSize: 13 }, + errorBlockTextDark: { color: "#fca5a5" }, + // Images imageScroll: { marginBottom: 8 }, imageRow: { gap: 8 }, diff --git a/src/components/chat/MessageTextSheet.tsx b/src/components/chat/MessageTextSheet.tsx new file mode 100644 index 00000000..9c6069e9 --- /dev/null +++ b/src/components/chat/MessageTextSheet.tsx @@ -0,0 +1,79 @@ +import { useState } from "react" +import { Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View, useColorScheme } from "react-native" +import * as Clipboard from "expo-clipboard" +import { useTranslation } from "react-i18next" + +interface Props { + visible: boolean + text: string + onClose: () => void +} + +// Full-screen selectable-text view for a chat message. +// +// Why a modal instead of selectable inside the bubble: markdown text +// nodes live in rows of the session screen's FlatList, where selectable Text +// hits the long-standing Android nesting bug (facebook/react-native#46999 — +// the reason Markdown.tsx drops `selectable` entirely). A plain ScrollView +// outside any virtualized list lets the platform's native selection +// (long-press → handles → copy) work reliably on both platforms, and gives a +// "copy all" fallback for whole-message copying. +export function MessageTextSheet({ visible, text, onClose }: Props) { + const { t } = useTranslation() + const isDark = useColorScheme() === "dark" + const [copied, setCopied] = useState(false) + + const copyAll = async () => { + await Clipboard.setStringAsync(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + + + {t("session.actions.selectText")} + + {copied ? t("session.actions.copied") : t("session.actions.copyAll")} + + + {t("common.close")} + + + + + {text} + + + + + ) +} + +const s = StyleSheet.create({ + container: { flex: 1, backgroundColor: "#fafafa" }, + containerDark: { backgroundColor: "#111111" }, + header: { + flexDirection: "row", + alignItems: "center", + gap: 12, + paddingHorizontal: 16, + paddingTop: 60, + paddingBottom: 12, + borderBottomWidth: 1, + borderBottomColor: "#e5e5e5", + }, + headerDark: { borderBottomColor: "#2a2a2a" }, + title: { flex: 1, fontSize: 16, fontWeight: "600", color: "#0a0a0a" }, + textWhite: { color: "#ffffff" }, + copyBtn: { paddingHorizontal: 10, paddingVertical: 6, backgroundColor: "#e8e5f0", borderRadius: 8 }, + copyBtnDark: { backgroundColor: "#2a2040" }, + copyText: { color: "#6d28d9", fontSize: 13, fontWeight: "600" }, + copyTextDark: { color: "#c4b5fd" }, + closeBtn: { paddingHorizontal: 10, paddingVertical: 6 }, + closeText: { color: "#666666", fontSize: 13 }, + closeTextDark: { color: "#888888" }, + scroll: { padding: 16 }, + body: { fontSize: 15, lineHeight: 23, color: "#0a0a0a" }, +}) diff --git a/src/components/chat/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..6c7d5494 --- /dev/null +++ b/src/lib/api-detect.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict" +import { test } from "node:test" + +import { 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), false) +}) diff --git a/src/lib/api-detect.ts b/src/lib/api-detect.ts new file mode 100644 index 00000000..499fab20 --- /dev/null +++ b/src/lib/api-detect.ts @@ -0,0 +1,80 @@ +// 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 api === "v1" +} 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/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..a52dbd48 --- /dev/null +++ b/src/lib/sdk-v1.ts @@ -0,0 +1,545 @@ +// 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 { 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 { done, value } = await reader.read() + if (done) { + console.log("[SSE] stream ended") + break + } + + if (!receivedFirstByte) { + receivedFirstByte = true + console.log(`[SSE] first byte received (${value?.byteLength ?? 0} bytes)`) + } + + for (const data of parser.push(decoder.decode(value, { stream: true }))) { + 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}`), + + 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(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") => + request(config, `/permission/${requestID}/reply`, { + method: "POST", + body: JSON.stringify({ reply }), + }), + }, + + 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..88bba1e5 100644 --- a/src/lib/sdk.ts +++ b/src/lib/sdk.ts @@ -1,13 +1,50 @@ -// 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 type { FileRoot } from "./file-roots" +import { createV1Client } from "./sdk-v1" +import { 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 +55,11 @@ export interface ClientConfig { username: string password: string } + // Which server API this connection speaks, as recorded by probeServer(). + // Omitted means "v2" (today's behaviour); "v1" routes to the legacy client in + // sdk-v1.ts, which speaks the pre-1.19 routes (/session, /global/event, + // /question, /permission) that the v2 server no longer serves. + api?: ServerApi } export interface Session { @@ -41,8 +83,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 +217,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 +233,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 +266,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 +303,35 @@ async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutM } } -export function createClient(config: ClientConfig) { +// Minimal shape of a stream reader we need for the idle watchdog (expo/fetch +// returns its own ReadableStream reader type; structural typing avoids fighting +// with it). +type ReaderLike = { + read: () => Promise<{ done?: boolean; value?: Uint8Array }> + cancel?: () => Promise +} + +// The v2 server emits an SSE comment frame (`: heartbeat`) every 15s, so any +// bytes on the wire prove the connection is alive. 45s of total silence means +// the stream is dead — the usual case after Android backgrounds/freezes the +// app, where the socket goes half-open: `read()` hangs forever instead of +// reporting a close. Without this watchdog the app would sit "connected" with +// a frozen event stream until the next HTTP call happens to fail. +const SSE_IDLE_TIMEOUT_MS = 45_000 + +async function readWithIdleTimeout(reader: ReaderLike, ms: number): Promise<{ done?: boolean; value?: Uint8Array } | "idle"> { + let timer: ReturnType | undefined + // Swallow the read rejection: if the fetch is aborted while this race is + // settling, the losing read promise must not become an unhandled rejection. + const read = reader.read().catch(() => ({ done: true, value: undefined })) + try { + return await Promise.race([read, new Promise<"idle">((resolve) => { timer = setTimeout(() => resolve("idle"), ms) })]) + } finally { + clearTimeout(timer) + } +} + +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 +339,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 +371,18 @@ 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 as ReaderLike, SSE_IDLE_TIMEOUT_MS) + if (chunk === "idle") { + console.warn(`[SSE] no data for ${SSE_IDLE_TIMEOUT_MS / 1000}s (server heartbeats every 15s) — dropping dead stream`) + void reader.cancel?.().catch(() => {}) + throw new Error("Event stream idle — no heartbeat received") + } + const { done, value } = chunk if (done) { console.log("[SSE] stream ended") break @@ -293,7 +395,10 @@ export function createClient(config: ClientConfig) { for (const data of parser.push(decoder.decode(value, { stream: true }))) { 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 +414,148 @@ 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", { + 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 +565,35 @@ 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 text = params.parts + .filter((p): p is { type: "text"; text: string } => p.type === "text") + .map((p) => p.text) + .join("\n") + const files = params.parts + .filter((p): p is { type: "file"; mime: string; url: string; filename?: string } => p.type === "file") + .map((p) => ({ uri: p.url, ...(p.filename ? { name: p.filename } : {}) })) + await request(config, `/api/session/${sessionID}/prompt`, { method: "POST", - headers, - body, + body: JSON.stringify({ text, files }), }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`Failed to send message: ${response.status} - ${error}`) - } }, command: async ( @@ -431,107 +607,179 @@ 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}`) + await request(config, `/api/session/${known.sessionID}/permission/${requestID}/reply`, { method: "POST", - body: JSON.stringify({ reply }), - }), + body: JSON.stringify({ decision: reply }), + }) + 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 --------------------------------------------- +// `npm install -g opencode-ai` still installs the v1 line (1.18.32 as of +// 2026-09-24), so a server that predates the /api/ rewrite is the common case, +// not an edge case. probeServer() decides which transport a connection uses; +// the answer is stored on the connection (ServerConnection.api) so every client +// built from it — including the per-directory ones from clientForDirectory — +// gets the same one without re-probing. +export function createClient(config: ClientConfig): Client { + if (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/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..d0695d83 --- /dev/null +++ b/src/lib/v2-shape.test.ts @@ -0,0 +1,359 @@ +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", id: "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("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..7d83ee59 --- /dev/null +++ b/src/lib/v2-shape.ts @@ -0,0 +1,820 @@ +// 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; id?: 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.id ?? "" } : 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": + case "session.execution.failed": { + if (!sessionID) return [] + const out: Event[] = [] + if (raw.type === "session.execution.failed") { + out.push(ev("session.error", { sessionID, error: { message: structuredErrorMessage(d.error) } })) + } + 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": { + if (!d.id || !d.sessionID) return [] + rememberPermissionRequest(d as V2PermissionRequest) + return [ev("permission.asked", toPermissionRequest(d as V2PermissionRequest))] + } + + case "permission.replied": + case "permission.rejected": { + if (!d.id) return [] + const known = pendingRequestInfo(d.id) + forgetPendingRequest(d.id) + const sid = d.sessionID ?? known?.sessionID + if (!sid) return [] + return [ev("permission.replied", { sessionID: sid, requestID: d.id })] + } + + 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..830a2296 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, @@ -105,9 +128,13 @@ export const useConnections = create((set, get) => ({ 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 + // 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)) // Fetch current project info and server paths try { const [proj, paths] = await Promise.all([ @@ -164,7 +191,8 @@ export const useConnections = create((set, get) => ({ if (newConnection.active) { activeConnection = newConnection const auth = buildAuth(newConnection.username, password) - const built = buildClient(newConnection.url, newConnection.directory, auth) + const { api } = await resolveApi(newConnection, auth) + const built = buildClient(newConnection.url, newConnection.directory, auth, api) client = built.client base = built.base @@ -202,7 +230,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 }) @@ -229,7 +259,8 @@ export const useConnections = create((set, get) => ({ 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 } = await resolveApi(active, auth) + const built = buildClient(active.url, active.directory, auth, api) client = built.client base = built.base @@ -260,15 +291,21 @@ export const useConnections = create((set, get) => ({ 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 +314,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,7 +342,9 @@ 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) + 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)) try { const [project, paths] = await Promise.all([ built.client.project.current().catch(() => null), @@ -340,7 +390,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..b4c8d1ee 100644 --- a/src/stores/events.ts +++ b/src/stores/events.ts @@ -57,6 +57,11 @@ interface EventsState { connect: () => void disconnect: () => 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 @@ -77,7 +82,9 @@ 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()]) + // 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 || []).filter((p: Record) => p.sessionID === sessionID) const sessionQuestions = (questions || []).filter((q: Record) => q.sessionID === sessionID) useEvents.setState((state) => ({ @@ -479,6 +486,34 @@ export const useEvents = create((set, get) => ({ })() }, + reconnectNow: () => { + const connState = useConnections.getState() + if (!connState.client) return + console.log("[SSE] Foreground reconnect — re-establishing event stream") + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + controller?.abort() + controller = null + // Reset backoff state first: connect() gates its post-reconnect busy + // resync on reconnectAttempts > 0, and a foreground resume must resync + // even from a previously "stable" (but actually dead) connection — so we + // run the resync explicitly below instead. + set({ reconnectAttempts: 0, lastDisconnectAt: null }) + get().connect() + void resyncBusySessions() + const sessions = useSessions.getState() + if (sessions.currentSession) { + sessions.refreshMessages() + const session = sessions.currentSession + const client = session.directory + ? connState.clientForDirectory(session.directory) ?? connState.client + : connState.client + if (client) void refreshPending(client, session.id) + } + }, + disconnect: () => { console.log("[SSE] Disconnecting") addBreadcrumb({ category: "sse", message: "disconnected" })